@decentnetwork/peer 0.1.73 → 0.1.75

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.
@@ -1,6 +1,7 @@
1
1
  export declare const PACKET_ID_FILE_SENDREQUEST = 80;
2
2
  export declare const PACKET_ID_FILE_CONTROL = 81;
3
3
  export declare const PACKET_ID_FILE_DATA = 82;
4
+ export declare const PACKET_ID_FILE_FEC = 83;
4
5
  export declare const FILE_ID_LENGTH = 32;
5
6
  export declare const MAX_FILENAME_LENGTH = 255;
6
7
  export declare const MAX_CONCURRENT_FILE_PIPES = 256;
@@ -23,14 +23,53 @@ import { concatBytes, randomBytes } from "../utils/bytes.js";
23
23
  import { createHash } from "node:crypto";
24
24
  import { existsSync, statSync, readFileSync, appendFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
25
25
  import { join } from "node:path";
26
+ import { rsEncode, rsDecode } from "./reed-solomon.js";
26
27
  export const PACKET_ID_FILE_SENDREQUEST = 80;
27
28
  export const PACKET_ID_FILE_CONTROL = 81;
28
29
  export const PACKET_ID_FILE_DATA = 82;
30
+ export const PACKET_ID_FILE_FEC = 83; // FEC parity shard (forward error correction)
29
31
  export const FILE_ID_LENGTH = 32;
30
32
  export const MAX_FILENAME_LENGTH = 255;
31
33
  export const MAX_CONCURRENT_FILE_PIPES = 256;
32
34
  // MAX_CRYPTO_DATA_SIZE (1373) − 1 (kind) − 1 (filenum) − 4 (offset).
33
35
  export const MAX_FILE_DATA_SIZE = 1367;
36
+ // FEC: a block is K data chunks; the sender adds M parity chunks so the receiver
37
+ // can reconstruct up to M lost chunks per block locally (no retransmit) — the
38
+ // key to bulk transfer over a lossy GFW path (measured 15-50% loss). ~37%
39
+ // overhead handles bursty ~15% loss with margin; the retransmit path remains a
40
+ // backstop for blocks that exceed FEC capacity.
41
+ const FEC_K = 16;
42
+ // Parity is ADAPTIVE per block: the sender sizes it to the loss rate the
43
+ // receiver reports (piggybacked on each ack). A fixed M=6 tolerated only
44
+ // ~27% loss, so a 40%-loss path (measured on lili) blew past FEC capacity and
45
+ // fell back to go-back-N retransmit — ~1% efficient. Now the sender sends just
46
+ // enough parity that blocks recover LOCALLY at the current loss, so the
47
+ // expensive retransmit path almost never fires.
48
+ const FEC_M_MIN = 4; // parity the instant loss is first detected (before the report warms up)
49
+ const FEC_M_MAX = 16; // ceiling on parity per K=16 block (covers ~50% loss)
50
+ const FEC_MARGIN = 2; // extra shards over the estimate — absorbs burst variance
51
+ const FEC_SAFETY = 1.15; // over-provision the loss estimate by 15%
52
+ // Adaptive: only spend parity bandwidth while the path is actually losing
53
+ // packets (a dup-ack was seen within this window). A clean path sends zero
54
+ // parity → no overhead; a lossy path (lili) gets full protection.
55
+ const FEC_ACTIVE_WINDOW_MS = 4000;
56
+ // FEC packet payload: [filenum(1)][blockIndex u32][parityIndex(1)][parity bytes].
57
+ // k (data chunks in the block) and m are NOT sent — both ends share FEC_K/FEC_M
58
+ // constants and derive the last block's k from the file size — so the 6-byte
59
+ // header + a full 1367-byte parity shard fits the 1373-byte crypto limit exactly
60
+ // and the data chunk size (MAX_FILE_DATA_SIZE) stays unchanged (0.1.73-compatible).
61
+ function encodeFec(fileNumber, blockIndex, parityIndex, parity) {
62
+ return concatBytes([Uint8Array.of(fileNumber & 0xff), u32be(blockIndex), Uint8Array.of(parityIndex & 0xff), parity]);
63
+ }
64
+ function parseFec(p) {
65
+ if (p.length < 6)
66
+ return undefined;
67
+ return { fileNumber: p[0], blockIndex: readU32be(p, 1), parityIndex: p[5], parity: p.slice(6) };
68
+ }
69
+ // How many data chunks are in block b of a file with `totalChunks` chunks.
70
+ function fecBlockK(blockIndex, totalChunks) {
71
+ return Math.min(FEC_K, totalChunks - blockIndex * FEC_K);
72
+ }
34
73
  export const FILECONTROL = { ACCEPT: 0, PAUSE: 1, KILL: 2, SEEK: 3, ACK: 4 };
35
74
  export const FILEKIND = { DATA: 0, AVATAR: 1 };
36
75
  // Reliability tuning. We RAMP an in-flight window (AIMD-lite) rather than pin it:
@@ -197,7 +236,7 @@ export class FileTransferManager {
197
236
  fileNumber, fileId, name: opts.name, data, size: data.length,
198
237
  acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
199
238
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
200
- cwnd: CWND_INIT_CHUNKS, stalls: 0,
239
+ cwnd: CWND_INIT_CHUNKS, stalls: 0, parityHighBlock: -1, lastLossMs: 0, lossEwma: 0,
201
240
  minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
202
241
  };
203
242
  this.#map(this.#sending, friendId).set(fileNumber, st);
@@ -269,6 +308,10 @@ export class FileTransferManager {
269
308
  this.#onData(friendId, payload);
270
309
  return true;
271
310
  }
311
+ if (packetId === PACKET_ID_FILE_FEC) {
312
+ this.#onFec(friendId, payload);
313
+ return true;
314
+ }
272
315
  return false;
273
316
  }
274
317
  #onSendRequest(friendId, payload) {
@@ -283,8 +326,8 @@ export class FileTransferManager {
283
326
  } // re-offer (our accept/ack lost) → re-ack
284
327
  const st = {
285
328
  fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
286
- buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
287
- contiguous: 0, maxByte: 0, persisted: 0, partPath: this.#partPath(r.fileId), done: false, ackDirty: false,
329
+ buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)), netGot: new Uint8Array(chunkCount(size)), lossPctEwma: 0,
330
+ contiguous: 0, maxByte: 0, persisted: 0, partPath: this.#partPath(r.fileId), fecParity: new Map(), done: false, ackDirty: false,
288
331
  };
289
332
  // 断点续传: if we persisted a contiguous prefix of this exact file (matched
290
333
  // by content-hash fileId) before a drop/restart, load it and resume — our
@@ -300,8 +343,10 @@ export class FileTransferManager {
300
343
  if (usable > 0) {
301
344
  const partial = readFileSync(st.partPath);
302
345
  st.buf.set(partial.subarray(0, usable), 0);
303
- for (let i = 0; i < chunks; i++)
346
+ for (let i = 0; i < chunks; i++) {
304
347
  st.got[i] = 1;
348
+ st.netGot[i] = 1;
349
+ }
305
350
  st.contiguous = usable;
306
351
  st.maxByte = usable;
307
352
  st.persisted = usable;
@@ -382,23 +427,118 @@ export class FileTransferManager {
382
427
  return; // not a chunk boundary
383
428
  if (offset + data.length > st.maxByte)
384
429
  st.maxByte = offset + data.length;
430
+ st.netGot[idx] = 1; // genuine network arrival (even if a dup) → raw-loss denominator
385
431
  if (!st.got[idx]) {
386
432
  st.buf.set(data, offset);
387
433
  st.got[idx] = 1;
388
- // Advance the contiguous prefix.
389
- let i = Math.floor(st.contiguous / MAX_FILE_DATA_SIZE);
390
- while (i < st.got.length && st.got[i])
391
- i++;
392
- st.contiguous = Math.min(i * MAX_FILE_DATA_SIZE, st.size);
434
+ this.#advanceContiguous(friendId, st);
435
+ }
436
+ // This chunk may complete a FEC block enough to reconstruct its other holes.
437
+ this.#tryRecoverBlock(friendId, st, Math.floor(idx / FEC_K));
438
+ this.#finishOrAck(friendId, st);
439
+ }
440
+ // Advance the contiguous prefix over received/recovered chunks, emitting
441
+ // progress and flushing the resume file. Does not finish or ack.
442
+ #advanceContiguous(friendId, st) {
443
+ let i = Math.floor(st.contiguous / MAX_FILE_DATA_SIZE);
444
+ while (i < st.got.length && st.got[i])
445
+ i++;
446
+ const nc = Math.min(i * MAX_FILE_DATA_SIZE, st.size);
447
+ if (nc > st.contiguous) {
448
+ st.contiguous = nc;
393
449
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.contiguous, total: st.size });
394
450
  this.#persistPartial(st);
395
451
  }
452
+ }
453
+ #finishOrAck(friendId, st) {
454
+ if (st.done)
455
+ return;
396
456
  if (st.contiguous >= st.size) {
397
457
  this.#finishRecv(friendId, st);
398
458
  return;
399
459
  }
400
460
  this.#scheduleAck(friendId, st);
401
461
  }
462
+ // A parity shard arrived → store it and try to repair its block.
463
+ #onFec(friendId, payload) {
464
+ const f = parseFec(payload);
465
+ if (!f)
466
+ return;
467
+ const st = this.#map(this.#receiving, friendId).get(f.fileNumber);
468
+ if (!st || st.done)
469
+ return;
470
+ if (f.parity.length !== MAX_FILE_DATA_SIZE || f.parityIndex >= FEC_M_MAX)
471
+ return;
472
+ let arr = st.fecParity.get(f.blockIndex);
473
+ if (!arr) {
474
+ arr = new Array(FEC_M_MAX).fill(undefined);
475
+ st.fecParity.set(f.blockIndex, arr);
476
+ }
477
+ if (!arr[f.parityIndex])
478
+ arr[f.parityIndex] = f.parity;
479
+ this.#tryRecoverBlock(friendId, st, f.blockIndex);
480
+ this.#finishOrAck(friendId, st);
481
+ }
482
+ // If FEC block `b` has at least K of its K+M shards (data + parity), rebuild
483
+ // the missing data chunks LOCALLY — no retransmit, so the sender's window
484
+ // never stalls waiting for the lost chunk.
485
+ #tryRecoverBlock(friendId, st, b) {
486
+ if (st.done || b < 0)
487
+ return;
488
+ const totalChunks = chunkCount(st.size);
489
+ if (b * FEC_K >= totalChunks)
490
+ return;
491
+ const k = fecBlockK(b, totalChunks);
492
+ const base = b * FEC_K;
493
+ const missing = [];
494
+ for (let i = 0; i < k; i++)
495
+ if (!st.got[base + i])
496
+ missing.push(i);
497
+ if (missing.length === 0) {
498
+ st.fecParity.delete(b);
499
+ return;
500
+ } // block already whole
501
+ const parityArr = st.fecParity.get(b);
502
+ const parityPresent = parityArr ? parityArr.reduce((n, s) => n + (s ? 1 : 0), 0) : 0;
503
+ if ((k - missing.length) + parityPresent < k)
504
+ return; // not enough shards yet
505
+ if (missing.length > parityPresent)
506
+ return; // can't cover this many holes
507
+ // Assemble the K+M shard array (present data padded to a full chunk).
508
+ const shards = [];
509
+ for (let i = 0; i < k; i++) {
510
+ if (st.got[base + i]) {
511
+ const off = (base + i) * MAX_FILE_DATA_SIZE;
512
+ const chunk = st.buf.subarray(off, Math.min(off + MAX_FILE_DATA_SIZE, st.size));
513
+ if (chunk.length === MAX_FILE_DATA_SIZE)
514
+ shards.push(chunk);
515
+ else {
516
+ const padded = new Uint8Array(MAX_FILE_DATA_SIZE);
517
+ padded.set(chunk);
518
+ shards.push(padded);
519
+ }
520
+ }
521
+ else
522
+ shards.push(null);
523
+ }
524
+ for (let pi = 0; pi < FEC_M_MAX; pi++)
525
+ shards.push(parityArr?.[pi] ?? null);
526
+ const recovered = rsDecode(shards, k, FEC_M_MAX, MAX_FILE_DATA_SIZE);
527
+ if (!recovered)
528
+ return;
529
+ for (const i of missing) {
530
+ const off = (base + i) * MAX_FILE_DATA_SIZE;
531
+ const realLen = Math.min(MAX_FILE_DATA_SIZE, st.size - off);
532
+ if (realLen <= 0)
533
+ continue;
534
+ st.buf.set(recovered[i].subarray(0, realLen), off);
535
+ st.got[base + i] = 1;
536
+ if (off + realLen > st.maxByte)
537
+ st.maxByte = off + realLen;
538
+ }
539
+ st.fecParity.delete(b); // recovered — free the parity
540
+ this.#advanceContiguous(friendId, st);
541
+ }
402
542
  // Flush the newly-contiguous bytes to the resume file, throttled so we do at
403
543
  // most one append per PERSIST_INTERVAL_BYTES of progress (not per chunk).
404
544
  #persistPartial(st, force = false) {
@@ -458,14 +598,37 @@ export class FileTransferManager {
458
598
  this.#sendAck(friendId, st);
459
599
  }, ACK_THROTTLE_MS);
460
600
  }
601
+ // Raw network loss (0..100) behind the frontier: the fraction of chunk slots
602
+ // between the contiguous prefix and the highest byte seen that have NOT arrived
603
+ // over the network (netGot=0). FEC-recovered holes still count as losses here
604
+ // (netGot stays 0), so the estimate doesn't collapse to zero the moment FEC
605
+ // patches a block — which would starve parity and reopen the hole. Self-
606
+ // correcting: under-provisioned parity leaves holes → the window fills with
607
+ // gaps → the reported loss rises → the sender adds parity.
608
+ #measureLoss(st) {
609
+ const lo = Math.floor(st.contiguous / MAX_FILE_DATA_SIZE);
610
+ const hi = Math.min(st.got.length, Math.ceil(st.maxByte / MAX_FILE_DATA_SIZE));
611
+ let span = 0, holes = 0;
612
+ for (let i = lo; i < hi && span < 4096; i++) {
613
+ span++;
614
+ if (!st.netGot[i])
615
+ holes++;
616
+ }
617
+ const inst = span > 0 ? holes / span : 0;
618
+ st.lossPctEwma = st.lossPctEwma === 0 ? inst : 0.7 * st.lossPctEwma + 0.3 * inst;
619
+ return Math.max(0, Math.min(100, Math.round(st.lossPctEwma * 100)));
620
+ }
461
621
  #sendAck(friendId, st) {
462
622
  st.ackDirty = false;
463
623
  // send_receive=1: the ACK refers to a file the PEER is SENDING (it lives in
464
- // the peer's #sending table). Carries [contiguous][maxByte] so the sender
465
- // sees the gap (maxByte contiguous) = loss/reorder, for congestion control.
466
- const body = new Uint8Array(8);
624
+ // the peer's #sending table). Carries [contiguous][maxByte][lossPct] so the
625
+ // sender sees the gap (for congestion control) and the raw loss rate (to size
626
+ // adaptive FEC parity). The 9th byte is additive — a pre-0.1.75 sender that
627
+ // reads only 8 bytes just ignores it.
628
+ const body = new Uint8Array(9);
467
629
  body.set(u32be(st.contiguous), 0);
468
630
  body.set(u32be(st.maxByte), 4);
631
+ body[8] = this.#measureLoss(st);
469
632
  void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(1, st.fileNumber, FILECONTROL.ACK, body)).catch(() => undefined);
470
633
  }
471
634
  // ── sender ────────────────────────────────────────────────────────
@@ -474,6 +637,12 @@ export class FileTransferManager {
474
637
  return;
475
638
  const ackedOffset = readU32be(data, 0);
476
639
  const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
640
+ // Receiver-reported raw loss (0..100) → drives adaptive FEC parity. Lightly
641
+ // re-smoothed here so a single ack can't swing the parity budget.
642
+ if (data.length >= 9) {
643
+ const reported = data[8] / 100;
644
+ st.lossEwma = 0.6 * st.lossEwma + 0.4 * reported;
645
+ }
477
646
  if (ackedOffset > st.acked) {
478
647
  // Progress. The path is delivering → additively re-open the window (up to
479
648
  // the cap) and clear the stall streak (patience shrinks back to the base).
@@ -532,6 +701,7 @@ export class FileTransferManager {
532
701
  // ~1 RTT so dup-ack bursts for one loss don't storm). No window backoff:
533
702
  // file-path loss is treated as random, not congestion.
534
703
  st.lastResendMs = nowMs();
704
+ st.lastLossMs = nowMs(); // the path is lossy → turn adaptive FEC on
535
705
  if (st.nextSend > st.acked)
536
706
  st.nextSend = st.acked;
537
707
  }
@@ -616,11 +786,61 @@ export class FileTransferManager {
616
786
  st.probeSentMs = nowMs();
617
787
  }
618
788
  }
789
+ // After sending data, emit FEC parity for any block whose data is now all
790
+ // sent, so the receiver can repair losses locally instead of stalling.
791
+ this.#emitParity(friendId, st);
619
792
  }
620
793
  finally {
621
794
  st.pumping = false;
622
795
  }
623
796
  }
797
+ // Send M parity shards for each FEC block whose K data chunks have all been
798
+ // sent (once per block). Parity is computed from the source file, so no state
799
+ // is kept beyond the high-water block index.
800
+ #emitParity(friendId, st) {
801
+ const totalChunks = chunkCount(st.size);
802
+ const totalBlocks = Math.ceil(totalChunks / FEC_K);
803
+ // Adaptive: only protect blocks while the path is actually lossy. On a clean
804
+ // path this sends zero parity (no overhead); the counter still advances so
805
+ // we never revisit a block.
806
+ const fecOn = nowMs() - st.lastLossMs < FEC_ACTIVE_WINDOW_MS;
807
+ while (st.parityHighBlock + 1 < totalBlocks) {
808
+ const b = st.parityHighBlock + 1;
809
+ const kActual = fecBlockK(b, totalChunks);
810
+ const blockEndByte = Math.min((b * FEC_K + kActual) * MAX_FILE_DATA_SIZE, st.size);
811
+ if (st.nextSend < blockEndByte)
812
+ break; // block's data not all sent yet
813
+ if (!fecOn) {
814
+ st.parityHighBlock = b;
815
+ continue;
816
+ } // clean path → skip parity
817
+ // Size parity to the reported loss p: to land k of (k+m) shards through a
818
+ // path dropping fraction p, need k+m ≥ k/(1−p) → m ≥ k·p/(1−p). Over-
819
+ // provision (SAFETY) and add a fixed MARGIN for burst variance; a fresh
820
+ // dup-ack (report not yet in) still gets FEC_M_MIN so we react immediately.
821
+ const p = Math.min(0.6, st.lossEwma); // cap so the divisor can't explode
822
+ const mNeed = Math.ceil((kActual * p / (1 - p)) * FEC_SAFETY) + FEC_MARGIN;
823
+ const mSend = Math.max(FEC_M_MIN, Math.min(FEC_M_MAX, mNeed));
824
+ // Build the k data shards (zero-padded to a full chunk) and encode mSend parity.
825
+ const shards = [];
826
+ for (let i = 0; i < kActual; i++) {
827
+ const off = (b * FEC_K + i) * MAX_FILE_DATA_SIZE;
828
+ const chunk = st.data.subarray(off, Math.min(off + MAX_FILE_DATA_SIZE, st.size));
829
+ if (chunk.length === MAX_FILE_DATA_SIZE)
830
+ shards.push(chunk);
831
+ else {
832
+ const padded = new Uint8Array(MAX_FILE_DATA_SIZE);
833
+ padded.set(chunk);
834
+ shards.push(padded);
835
+ }
836
+ }
837
+ const parity = rsEncode(shards, mSend);
838
+ for (let pi = 0; pi < parity.length; pi++) {
839
+ void this.send(friendId, PACKET_ID_FILE_FEC, encodeFec(st.fileNumber, b, pi, parity[pi])).catch(() => undefined);
840
+ }
841
+ st.parityHighBlock = b;
842
+ }
843
+ }
624
844
  #completeSend(friendId, st) {
625
845
  const durationMs = nowMs() - st.startMs;
626
846
  const mbps = durationMs > 0 ? st.size / 1024 / 1024 / (durationMs / 1000) : 0;
@@ -0,0 +1,9 @@
1
+ /** Encode: given K equal-length data shards, return M parity shards. */
2
+ export declare function rsEncode(data: Uint8Array[], m: number): Uint8Array[];
3
+ /**
4
+ * Recover the K data shards from any K present shards (data and/or parity).
5
+ * `shards` is the full K+M array with missing entries as null; `len` is the
6
+ * shard length. Returns the K reconstructed data shards, or null if fewer than
7
+ * K shards are present (unrecoverable).
8
+ */
9
+ export declare function rsDecode(shards: Array<Uint8Array | null>, k: number, m: number, len: number): Uint8Array[] | null;
@@ -0,0 +1,198 @@
1
+ //
2
+ // Reed-Solomon ERASURE coding over GF(256), for FEC on lossy paths.
3
+ //
4
+ // The file transfer runs over a best-effort channel that, on a GFW-grade path,
5
+ // drops 15-50% of packets. Retransmit-based recovery collapses throughput
6
+ // (measured: ~1% of the link's real 1.3 MB/s). FEC instead sends a few PARITY
7
+ // shards alongside each block of K data shards; the receiver reconstructs any
8
+ // missing data shards LOCALLY as long as it got at least K of the K+M shards —
9
+ // no round-trip, so the send window never stalls on loss.
10
+ //
11
+ // This is *erasure* coding, not error correction: the receiver knows exactly
12
+ // which shards are missing (each chunk carries its offset), so recovery is just
13
+ // solving a linear system — no syndromes or error locators. We use a systematic
14
+ // Cauchy-Reed-Solomon matrix: the first K rows are the identity (data shards
15
+ // pass through untouched), the last M rows are a Cauchy matrix (the parity).
16
+ //
17
+ // All shards in a block must be the same length; the caller zero-pads.
18
+ //
19
+ // ── GF(256) with primitive polynomial 0x11d (x^8+x^4+x^3+x^2+1) ──────────────
20
+ const EXP = new Uint8Array(512);
21
+ const LOG = new Uint8Array(256);
22
+ (function initTables() {
23
+ let x = 1;
24
+ for (let i = 0; i < 255; i++) {
25
+ EXP[i] = x;
26
+ LOG[x] = i;
27
+ x <<= 1;
28
+ if (x & 0x100)
29
+ x ^= 0x11d;
30
+ }
31
+ for (let i = 255; i < 512; i++)
32
+ EXP[i] = EXP[i - 255];
33
+ })();
34
+ function gmul(a, b) {
35
+ if (a === 0 || b === 0)
36
+ return 0;
37
+ return EXP[LOG[a] + LOG[b]];
38
+ }
39
+ function gdiv(a, b) {
40
+ if (b === 0)
41
+ throw new Error("gf div by zero");
42
+ if (a === 0)
43
+ return 0;
44
+ return EXP[LOG[a] + 255 - LOG[b]];
45
+ }
46
+ function ginv(a) {
47
+ if (a === 0)
48
+ throw new Error("gf inv of zero");
49
+ return EXP[255 - LOG[a]];
50
+ }
51
+ // Build the M×K Cauchy parity matrix. Cauchy element = 1 / (x_i XOR y_j) with
52
+ // disjoint x (parity index space) and y (data index space). Guaranteed
53
+ // invertible submatrices → any K of K+M shards recover the data.
54
+ function cauchyParityMatrix(k, m) {
55
+ const rows = [];
56
+ for (let i = 0; i < m; i++) {
57
+ const row = new Uint8Array(k);
58
+ const xi = k + i; // parity nodes live at k..k+m-1
59
+ for (let j = 0; j < k; j++) {
60
+ row[j] = ginv(xi ^ j); // xi ^ j is never 0 since xi >= k > j
61
+ }
62
+ rows.push(row);
63
+ }
64
+ return rows;
65
+ }
66
+ /** Encode: given K equal-length data shards, return M parity shards. */
67
+ export function rsEncode(data, m) {
68
+ const k = data.length;
69
+ if (k === 0 || m === 0)
70
+ return [];
71
+ const len = data[0].length;
72
+ const parity = cauchyParityMatrix(k, m);
73
+ const out = [];
74
+ for (let i = 0; i < m; i++) {
75
+ const shard = new Uint8Array(len);
76
+ const row = parity[i];
77
+ for (let j = 0; j < k; j++) {
78
+ const coeff = row[j];
79
+ if (coeff === 0)
80
+ continue;
81
+ const src = data[j];
82
+ const lc = LOG[coeff];
83
+ for (let b = 0; b < len; b++) {
84
+ const s = src[b];
85
+ if (s !== 0)
86
+ shard[b] ^= EXP[lc + LOG[s]];
87
+ }
88
+ }
89
+ out.push(shard);
90
+ }
91
+ return out;
92
+ }
93
+ // Invert a K×K matrix in GF(256) via Gauss-Jordan. Returns null if singular
94
+ // (shouldn't happen for a Cauchy-derived submatrix, but guard anyway).
95
+ function invert(matrix, k) {
96
+ // Augment [M | I].
97
+ const a = matrix.map((r) => Uint8Array.from(r));
98
+ const inv = Array.from({ length: k }, (_, i) => {
99
+ const r = new Uint8Array(k);
100
+ r[i] = 1;
101
+ return r;
102
+ });
103
+ for (let col = 0; col < k; col++) {
104
+ // Find a pivot.
105
+ let piv = col;
106
+ while (piv < k && a[piv][col] === 0)
107
+ piv++;
108
+ if (piv === k)
109
+ return null;
110
+ if (piv !== col) {
111
+ [a[piv], a[col]] = [a[col], a[piv]];
112
+ [inv[piv], inv[col]] = [inv[col], inv[piv]];
113
+ }
114
+ // Normalize the pivot row.
115
+ const pv = a[col][col];
116
+ const pvInv = ginv(pv);
117
+ for (let j = 0; j < k; j++) {
118
+ a[col][j] = gmul(a[col][j], pvInv);
119
+ inv[col][j] = gmul(inv[col][j], pvInv);
120
+ }
121
+ // Eliminate the column from all other rows.
122
+ for (let row = 0; row < k; row++) {
123
+ if (row === col)
124
+ continue;
125
+ const f = a[row][col];
126
+ if (f === 0)
127
+ continue;
128
+ const lf = LOG[f];
129
+ for (let j = 0; j < k; j++) {
130
+ if (a[col][j] !== 0)
131
+ a[row][j] ^= EXP[lf + LOG[a[col][j]]];
132
+ if (inv[col][j] !== 0)
133
+ inv[row][j] ^= EXP[lf + LOG[inv[col][j]]];
134
+ }
135
+ }
136
+ }
137
+ return inv;
138
+ }
139
+ /**
140
+ * Recover the K data shards from any K present shards (data and/or parity).
141
+ * `shards` is the full K+M array with missing entries as null; `len` is the
142
+ * shard length. Returns the K reconstructed data shards, or null if fewer than
143
+ * K shards are present (unrecoverable).
144
+ */
145
+ export function rsDecode(shards, k, m, len) {
146
+ // Fast path: all K data shards present.
147
+ if (shards.slice(0, k).every((s) => s !== null))
148
+ return shards.slice(0, k);
149
+ // Collect the first K present shards and the rows of the full encoding matrix
150
+ // (identity for data rows, Cauchy for parity rows) that produced them.
151
+ const parity = cauchyParityMatrix(k, m);
152
+ const encRow = (idx) => {
153
+ if (idx < k) {
154
+ const r = new Uint8Array(k);
155
+ r[idx] = 1;
156
+ return r;
157
+ } // identity row
158
+ return parity[idx - k];
159
+ };
160
+ const subMatrix = [];
161
+ const present = [];
162
+ for (let idx = 0; idx < k + m && subMatrix.length < k; idx++) {
163
+ const s = shards[idx];
164
+ if (s) {
165
+ subMatrix.push(encRow(idx));
166
+ present.push(s);
167
+ }
168
+ }
169
+ if (subMatrix.length < k)
170
+ return null; // not enough shards
171
+ const invMatrix = invert(subMatrix, k);
172
+ if (!invMatrix)
173
+ return null;
174
+ // data = inv(subMatrix) × present.
175
+ const data = [];
176
+ for (let i = 0; i < k; i++) {
177
+ if (shards[i]) {
178
+ data.push(shards[i]);
179
+ continue;
180
+ } // already have it
181
+ const shard = new Uint8Array(len);
182
+ const row = invMatrix[i];
183
+ for (let j = 0; j < k; j++) {
184
+ const coeff = row[j];
185
+ if (coeff === 0)
186
+ continue;
187
+ const src = present[j];
188
+ const lc = LOG[coeff];
189
+ for (let b = 0; b < len; b++) {
190
+ const sv = src[b];
191
+ if (sv !== 0)
192
+ shard[b] ^= EXP[lc + LOG[sv]];
193
+ }
194
+ }
195
+ data.push(shard);
196
+ }
197
+ return data;
198
+ }
package/dist/peer.js CHANGED
@@ -9,7 +9,7 @@ import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO,
9
9
  import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, openOnionAnnounceResponse, openOnionDataPacket, openOnionDataResponse } from "./compat/tox-onion.js";
10
10
  import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
11
11
  import { NET_PACKET_PING_REQUEST, NET_PACKET_PING_RESPONSE, NET_PACKET_GET_NODES, NET_PACKET_SEND_NODES, encodeDhtRpc, decodeDhtRpc, buildPingPlain, parsePingPlain, buildGetNodesPlain, parseGetNodesPlain, buildSendNodesPlain, parseSendNodesNodeBytes, packUdpNodeV4 } from "./compat/dht-rpc.js";
12
- import { FileTransferManager, PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_CONTROL, PACKET_ID_FILE_DATA } from "./compat/filetransfer.js";
12
+ import { FileTransferManager, PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_CONTROL, PACKET_ID_FILE_DATA, PACKET_ID_FILE_FEC } from "./compat/filetransfer.js";
13
13
  import { NET_PACKET_CRYPTO_DATA, NET_PACKET_CRYPTO_HS, NET_PACKET_COOKIE_REQUEST, NET_PACKET_COOKIE_RESPONSE, createCookieRequest, createCookieResponse, createCryptoDataPacket, createCryptoHandshake, openCookieRequest, openCookieResponse, openCryptoDataPacket, openCryptoHandshake, incrementNonce } from "./compat/net-crypto.js";
14
14
  import { LegacyExpressClient } from "./compat/express.js";
15
15
  import { TcpRelayPool } from "./compat/tcp-relay-pool.js";
@@ -1649,7 +1649,8 @@ export class Peer {
1649
1649
  const isSingleTransportKind = (this.#opts.bulkDataPacketId !== undefined && innerKind === this.#opts.bulkDataPacketId) ||
1650
1650
  innerKind === PACKET_ID_FILE_SENDREQUEST ||
1651
1651
  innerKind === PACKET_ID_FILE_CONTROL ||
1652
- innerKind === PACKET_ID_FILE_DATA;
1652
+ innerKind === PACKET_ID_FILE_DATA ||
1653
+ innerKind === PACKET_ID_FILE_FEC;
1653
1654
  // Confirm the transport path + liveness for EVERY decrypted packet —
1654
1655
  // INCLUDING a dual-send duplicate. Receiving over this transport proves
1655
1656
  // it works, so this MUST run BEFORE the dedup drop below. Otherwise the
@@ -2922,7 +2923,7 @@ export class Peer {
2922
2923
  // happens to finish before reordering bites. Pinning file transfer to a
2923
2924
  // single transport keeps the chunks in send order. (Reliability across a
2924
2925
  // lossy path still wants a reorder buffer + retransmit — tracked separately.)
2925
- const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA;
2926
+ const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
2926
2927
  const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
2927
2928
  // File transfer rides the relay RELIABLY (never dropped under backpressure):
2928
2929
  // a dropped IP/CCTV packet is fine (the stream moves on), but a dropped file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
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",