@decentnetwork/peer 0.1.49 → 0.1.50

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.
@@ -4,12 +4,13 @@ export declare const PACKET_ID_FILE_DATA = 82;
4
4
  export declare const FILE_ID_LENGTH = 32;
5
5
  export declare const MAX_FILENAME_LENGTH = 255;
6
6
  export declare const MAX_CONCURRENT_FILE_PIPES = 256;
7
- export declare const MAX_FILE_DATA_SIZE = 1371;
7
+ export declare const MAX_FILE_DATA_SIZE = 1367;
8
8
  export declare const FILECONTROL: {
9
9
  readonly ACCEPT: 0;
10
10
  readonly PAUSE: 1;
11
11
  readonly KILL: 2;
12
12
  readonly SEEK: 3;
13
+ readonly ACK: 4;
13
14
  };
14
15
  export declare const FILEKIND: {
15
16
  readonly DATA: 0;
@@ -30,9 +31,10 @@ export declare function parseFileControl(p: Uint8Array): {
30
31
  controlType: number;
31
32
  data: Uint8Array;
32
33
  } | undefined;
33
- export declare function encodeFileData(fileNumber: number, data: Uint8Array): Uint8Array;
34
+ export declare function encodeFileData(fileNumber: number, offset: number, data: Uint8Array): Uint8Array;
34
35
  export declare function parseFileData(p: Uint8Array): {
35
36
  fileNumber: number;
37
+ offset: number;
36
38
  data: Uint8Array;
37
39
  } | undefined;
38
40
  export type FtSend = (friendId: string, packetId: number, payload: Uint8Array) => Promise<unknown>;
@@ -42,17 +44,12 @@ export declare class FileTransferManager {
42
44
  private readonly send;
43
45
  private readonly emit;
44
46
  constructor(send: FtSend, emit: FtEmit);
45
- /** Offer a file to a friend. Returns the fileId (hex) or null if no slot. */
46
47
  sendFile(friendId: string, data: Uint8Array, opts: {
47
48
  name: string;
48
49
  kind?: number;
49
50
  }): string | null;
50
- /** Accept an incoming offer (by the friend + the offered fileNumber). */
51
51
  accept(friendId: string, fileNumber: number): void;
52
- /** Cancel a transfer (sending or receiving). */
53
52
  cancel(friendId: string, fileNumber: number, isSending: boolean): void;
54
- /** Route an inbound messenger packet. Returns true if it was a file packet. */
55
53
  handlePacket(friendId: string, packetId: number, payload: Uint8Array): boolean;
56
- /** Drop all transfers for a friend (e.g. on disconnect). */
57
54
  clearFriend(friendId: string): void;
58
55
  }
@@ -1,13 +1,24 @@
1
- // Toxcore-standard file transfer (Method "B") wire-compatible with the file
2
- // transfer already implemented in the C++ Carrier SDK's bundled toxcore
3
- // (tox_file_send / file_recv*). Rides the messenger / net_crypto lossless
4
- // channel; reassembled sequentially. No express, no 5 MB cap.
1
+ // Reliable file transfer over the messenger / net_crypto channel.
2
+ //
3
+ // The JS net_crypto layer is best-effort (no retransmit queue), so a file
4
+ // CANNOT just be streamed over it any UDP-lost chunk would be a permanent
5
+ // gap (observed: a 4.1 MB mp4 arriving as 3.9 MB, corrupt). This module adds
6
+ // its own TCP-like reliability ON TOP: every chunk carries an explicit byte
7
+ // offset, the receiver buffers by offset + acks the highest contiguous offset,
8
+ // and the sender uses a sliding window with timeout retransmission. That gives
9
+ // fast (windowed, UDP-speed) AND correct (byte-exact) transfers without the
10
+ // relay, and real progress/completion based on actual receipt (the sender only
11
+ // completes when the receiver has acked every byte).
5
12
  //
6
13
  // Wire (messenger packet payloads):
7
- // FILE_SENDREQUEST(80): [filenum(1)][file_type u32 BE][file_size u64 BE][file_id(32)][filename(≤255)]
14
+ // FILE_SENDREQUEST(80): [filenum(1)][file_type u32 BE][file_size u64 BE][file_id(32)][filename]
8
15
  // FILE_CONTROL(81): [send_receive(1)][filenum(1)][control_type(1)][optional data]
9
- // FILE_DATA(82): [filenum(1)][data MAX_FILE_DATA_SIZE] (position implicit; empty = EOF)
16
+ // ACK(4) data = [acked_offset u32 BE] (highest contiguous byte received)
17
+ // FILE_DATA(82): [filenum(1)][offset u32 BE][data] (offset = byte position)
10
18
  // send_receive: 1 ⇒ filenum is in the RECIPIENT's *sending* table, 0 ⇒ *receiving*.
19
+ // NOTE: the offset makes FILE_DATA differ from upstream toxcore (which has an
20
+ // implicit position); both ends here are the JS SDK. A native-interop mode
21
+ // (Phase 2) can negotiate the plain format via the send-request.
11
22
  import { concatBytes, randomBytes } from "../utils/bytes.js";
12
23
  export const PACKET_ID_FILE_SENDREQUEST = 80;
13
24
  export const PACKET_ID_FILE_CONTROL = 81;
@@ -15,10 +26,15 @@ export const PACKET_ID_FILE_DATA = 82;
15
26
  export const FILE_ID_LENGTH = 32;
16
27
  export const MAX_FILENAME_LENGTH = 255;
17
28
  export const MAX_CONCURRENT_FILE_PIPES = 256;
18
- // MAX_CRYPTO_DATA_SIZE (1373) − 2; conservative so a chunk always fits one packet.
19
- export const MAX_FILE_DATA_SIZE = 1371;
20
- export const FILECONTROL = { ACCEPT: 0, PAUSE: 1, KILL: 2, SEEK: 3 };
29
+ // MAX_CRYPTO_DATA_SIZE (1373) − 1 (kind) 1 (filenum) 4 (offset).
30
+ export const MAX_FILE_DATA_SIZE = 1367;
31
+ export const FILECONTROL = { ACCEPT: 0, PAUSE: 1, KILL: 2, SEEK: 3, ACK: 4 };
21
32
  export const FILEKIND = { DATA: 0, AVATAR: 1 };
33
+ // Reliability tuning.
34
+ const WINDOW_CHUNKS = 192; // chunks in flight (≈ 256 KB) for throughput on high-BDP paths
35
+ const RETRANSMIT_MS = 600; // resend window from the ack point if no progress in this long
36
+ const ACK_THROTTLE_MS = 25; // coalesce receiver acks
37
+ const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
22
38
  function u32be(n) {
23
39
  const b = new Uint8Array(4);
24
40
  b[0] = (n >>> 24) & 0xff;
@@ -71,19 +87,20 @@ export function parseFileControl(p) {
71
87
  return undefined;
72
88
  return { sendReceive: p[0], fileNumber: p[1], controlType: p[2], data: p.slice(3) };
73
89
  }
74
- export function encodeFileData(fileNumber, data) {
75
- return concatBytes([Uint8Array.of(fileNumber & 0xff), data]);
90
+ export function encodeFileData(fileNumber, offset, data) {
91
+ return concatBytes([Uint8Array.of(fileNumber & 0xff), u32be(offset), data]);
76
92
  }
77
93
  export function parseFileData(p) {
78
- if (p.length < 1)
94
+ if (p.length < 5)
79
95
  return undefined;
80
- return { fileNumber: p[0], data: p.slice(1) };
96
+ return { fileNumber: p[0], offset: readU32be(p, 1), data: p.slice(5) };
81
97
  }
82
98
  const hex = (b) => Buffer.from(b).toString("hex");
99
+ const nowMs = () => Date.now();
100
+ const chunkCount = (size) => (size <= 0 ? 0 : Math.ceil(size / MAX_FILE_DATA_SIZE));
83
101
  export class FileTransferManager {
84
102
  send;
85
103
  emit;
86
- // per friend → per fileNumber
87
104
  #sending = new Map();
88
105
  #receiving = new Map();
89
106
  constructor(send, emit) {
@@ -105,34 +122,52 @@ export class FileTransferManager {
105
122
  return i;
106
123
  return -1;
107
124
  }
108
- /** Offer a file to a friend. Returns the fileId (hex) or null if no slot. */
109
125
  sendFile(friendId, data, opts) {
110
126
  const fileNumber = this.#freeNumber(friendId);
111
127
  if (fileNumber < 0)
112
128
  return null;
113
129
  const fileId = randomBytes(FILE_ID_LENGTH);
114
- const st = { fileNumber, fileId, name: opts.name, data, size: data.length, transferred: 0, accepted: false, pumping: false };
130
+ const st = {
131
+ fileNumber, fileId, name: opts.name, data, size: data.length,
132
+ acked: 0, pumping: false, lastProgressAcked: 0, lastAckAdvanceMs: nowMs(),
133
+ };
115
134
  this.#map(this.#sending, friendId).set(fileNumber, st);
116
135
  const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
117
136
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
118
137
  return hex(fileId);
119
138
  }
120
- /** Accept an incoming offer (by the friend + the offered fileNumber). */
121
139
  accept(friendId, fileNumber) {
122
140
  const st = this.#map(this.#receiving, friendId).get(fileNumber);
123
- if (!st || st.accepted)
141
+ if (!st || st.done)
124
142
  return;
125
- st.accepted = true;
126
- // send_receive=1 → the filenumber lives in the SENDER's sending table.
127
143
  void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(1, fileNumber, FILECONTROL.ACCEPT)).catch(() => undefined);
144
+ this.#sendAck(friendId, st); // prime the sender's window with offset 0
145
+ // Periodic re-ack: if the ACCEPT (or an ack) is lost, the sender would
146
+ // never start / would stall; re-sending our cumulative ack restarts its
147
+ // pump and keeps it informed until the transfer finishes.
148
+ if (!st.reackTimer) {
149
+ st.reackTimer = setInterval(() => {
150
+ const cur = this.#map(this.#receiving, friendId).get(fileNumber);
151
+ if (!cur || cur.done) {
152
+ if (st.reackTimer)
153
+ clearInterval(st.reackTimer);
154
+ st.reackTimer = undefined;
155
+ return;
156
+ }
157
+ this.#sendAck(friendId, cur);
158
+ }, RETRANSMIT_MS);
159
+ if (typeof st.reackTimer.unref === "function")
160
+ st.reackTimer.unref();
161
+ }
128
162
  }
129
- /** Cancel a transfer (sending or receiving). */
130
163
  cancel(friendId, fileNumber, isSending) {
131
164
  const sr = isSending ? 0 : 1;
132
165
  void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(sr, fileNumber, FILECONTROL.KILL)).catch(() => undefined);
133
- (isSending ? this.#map(this.#sending, friendId) : this.#map(this.#receiving, friendId)).delete(fileNumber);
166
+ if (isSending)
167
+ this.#endSend(friendId, fileNumber);
168
+ else
169
+ this.#endRecv(friendId, fileNumber);
134
170
  }
135
- /** Route an inbound messenger packet. Returns true if it was a file packet. */
136
171
  handlePacket(friendId, packetId, payload) {
137
172
  if (packetId === PACKET_ID_FILE_SENDREQUEST) {
138
173
  this.#onSendRequest(friendId, payload);
@@ -152,25 +187,37 @@ export class FileTransferManager {
152
187
  const r = parseFileSendRequest(payload);
153
188
  if (!r)
154
189
  return;
155
- const st = { fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size: Number(r.fileSize), chunks: [], received: 0, accepted: false };
190
+ const size = Number(r.fileSize);
191
+ const existing = this.#map(this.#receiving, friendId).get(r.fileNumber);
192
+ if (existing && !existing.done)
193
+ return; // active transfer already on this slot
194
+ const st = {
195
+ fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
196
+ buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
197
+ contiguous: 0, done: false, ackDirty: false,
198
+ };
156
199
  this.#map(this.#receiving, friendId).set(r.fileNumber, st);
157
- this.emit("file-offer", { friendId, fileNumber: r.fileNumber, fileId: hex(r.fileId), name: r.filename, size: st.size, kind: r.fileType });
200
+ this.emit("file-offer", { friendId, fileNumber: r.fileNumber, fileId: hex(r.fileId), name: r.filename, size, kind: r.fileType });
201
+ if (size === 0)
202
+ this.#finishRecv(friendId, st);
158
203
  }
159
204
  #onControl(friendId, payload) {
160
205
  const c = parseFileControl(payload);
161
206
  if (!c)
162
207
  return;
163
- // send_receive=1 → refers to a file WE are sending; 0 → a file we are receiving.
164
208
  if (c.sendReceive === 1) {
209
+ // Refers to a file WE are sending.
165
210
  const st = this.#map(this.#sending, friendId).get(c.fileNumber);
166
211
  if (!st)
167
212
  return;
168
213
  if (c.controlType === FILECONTROL.ACCEPT) {
169
- st.accepted = true;
170
214
  void this.#pump(friendId, st);
171
215
  }
216
+ else if (c.controlType === FILECONTROL.ACK) {
217
+ this.#onAck(friendId, st, c.data);
218
+ }
172
219
  else if (c.controlType === FILECONTROL.KILL) {
173
- this.#map(this.#sending, friendId).delete(c.fileNumber);
220
+ this.#endSend(friendId, c.fileNumber);
174
221
  this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true });
175
222
  }
176
223
  }
@@ -179,11 +226,12 @@ export class FileTransferManager {
179
226
  if (!st)
180
227
  return;
181
228
  if (c.controlType === FILECONTROL.KILL) {
182
- this.#map(this.#receiving, friendId).delete(c.fileNumber);
229
+ this.#endRecv(friendId, c.fileNumber);
183
230
  this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: false });
184
231
  }
185
232
  }
186
233
  }
234
+ // ── receiver ──────────────────────────────────────────────────────
187
235
  #onData(friendId, payload) {
188
236
  const d = parseFileData(payload);
189
237
  if (!d)
@@ -191,49 +239,190 @@ export class FileTransferManager {
191
239
  const st = this.#map(this.#receiving, friendId).get(d.fileNumber);
192
240
  if (!st)
193
241
  return;
194
- if (d.data.length === 0 || (st.size !== 0 && st.received >= st.size)) {
195
- // EOF (empty final chunk) or all bytes in.
196
- const full = concatBytes(st.chunks);
197
- this.#map(this.#receiving, friendId).delete(d.fileNumber);
198
- this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, data: full });
242
+ if (st.done) {
243
+ this.#sendAck(friendId, st);
244
+ return;
245
+ } // re-ack: recover a lost final ack
246
+ const { offset, data } = d;
247
+ if (data.length === 0 || offset + data.length > st.size)
248
+ return; // malformed/stale
249
+ const idx = Math.floor(offset / MAX_FILE_DATA_SIZE);
250
+ if (offset % MAX_FILE_DATA_SIZE !== 0 || idx >= st.got.length)
251
+ return; // not a chunk boundary
252
+ if (!st.got[idx]) {
253
+ st.buf.set(data, offset);
254
+ st.got[idx] = 1;
255
+ // Advance the contiguous prefix.
256
+ let i = Math.floor(st.contiguous / MAX_FILE_DATA_SIZE);
257
+ while (i < st.got.length && st.got[i])
258
+ i++;
259
+ st.contiguous = Math.min(i * MAX_FILE_DATA_SIZE, st.size);
260
+ this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.contiguous, total: st.size });
261
+ }
262
+ if (st.contiguous >= st.size) {
263
+ this.#finishRecv(friendId, st);
264
+ return;
265
+ }
266
+ this.#scheduleAck(friendId, st);
267
+ }
268
+ #finishRecv(friendId, st) {
269
+ st.done = true;
270
+ if (st.ackTimer) {
271
+ clearTimeout(st.ackTimer);
272
+ st.ackTimer = undefined;
273
+ }
274
+ if (st.reackTimer) {
275
+ clearInterval(st.reackTimer);
276
+ st.reackTimer = undefined;
277
+ }
278
+ this.#sendAck(friendId, st); // final ack so the sender completes
279
+ const data = st.buf;
280
+ this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, data });
281
+ // Keep a lightweight done-marker (st.contiguous == size) so a LOST final
282
+ // ack can be recovered: any further FILE_DATA for this slot re-acks. Free
283
+ // the big buffers now; drop the marker after a grace window.
284
+ st.buf = new Uint8Array(0);
285
+ st.got = new Uint8Array(0);
286
+ const t = setTimeout(() => {
287
+ const cur = this.#map(this.#receiving, friendId).get(st.fileNumber);
288
+ if (cur === st)
289
+ this.#map(this.#receiving, friendId).delete(st.fileNumber);
290
+ }, 10000);
291
+ if (typeof t.unref === "function")
292
+ t.unref();
293
+ }
294
+ #scheduleAck(friendId, st) {
295
+ st.ackDirty = true;
296
+ if (st.ackTimer)
297
+ return;
298
+ st.ackTimer = setTimeout(() => {
299
+ st.ackTimer = undefined;
300
+ if (st.ackDirty && !st.done)
301
+ this.#sendAck(friendId, st);
302
+ }, ACK_THROTTLE_MS);
303
+ }
304
+ #sendAck(friendId, st) {
305
+ st.ackDirty = false;
306
+ // send_receive=1: the ACK refers to a file the PEER is SENDING (it lives in
307
+ // the peer's #sending table), so its #onControl routes it there.
308
+ void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(1, st.fileNumber, FILECONTROL.ACK, u32be(st.contiguous))).catch(() => undefined);
309
+ }
310
+ // ── sender ────────────────────────────────────────────────────────
311
+ #onAck(friendId, st, data) {
312
+ if (data.length < 4)
199
313
  return;
314
+ const ackedOffset = readU32be(data, 0);
315
+ if (ackedOffset > st.acked) {
316
+ st.acked = Math.min(ackedOffset, st.size);
317
+ st.lastAckAdvanceMs = nowMs();
318
+ if (st.acked - st.lastProgressAcked >= 64 * 1024 || st.acked >= st.size) {
319
+ st.lastProgressAcked = st.acked;
320
+ this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
321
+ }
200
322
  }
201
- st.chunks.push(d.data);
202
- st.received += d.data.length;
203
- this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.received, total: st.size });
204
- if (st.size !== 0 && st.received >= st.size) {
205
- const full = concatBytes(st.chunks);
206
- this.#map(this.#receiving, friendId).delete(d.fileNumber);
207
- this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, data: full });
323
+ if (st.acked >= st.size) {
324
+ this.#completeSend(friendId, st);
325
+ return;
208
326
  }
327
+ void this.#pump(friendId, st); // window slid → send more
209
328
  }
210
- // Pump chunks over the lossless messenger channel. net_crypto applies its own
211
- // congestion control, so awaiting each send gives natural backpressure.
212
329
  async #pump(friendId, st) {
213
330
  if (st.pumping)
214
331
  return;
215
332
  st.pumping = true;
333
+ // Retransmit watchdog: if the ack hasn't advanced within RETRANSMIT_MS,
334
+ // rewind the send cursor to the ack point (go-back-N) so the lost chunk and
335
+ // anything after it are re-sent. Cumulative acks make this self-correcting.
336
+ if (!st.timer) {
337
+ let stalledSinceMs = nowMs();
338
+ st.timer = setInterval(() => {
339
+ const cur = this.#map(this.#sending, friendId).get(st.fileNumber);
340
+ if (!cur) {
341
+ if (st.timer)
342
+ clearInterval(st.timer);
343
+ st.timer = undefined;
344
+ return;
345
+ }
346
+ if (st.acked >= st.size)
347
+ return;
348
+ const sinceAck = nowMs() - st.lastAckAdvanceMs;
349
+ if (sinceAck < RETRANSMIT_MS) {
350
+ stalledSinceMs = nowMs();
351
+ return;
352
+ }
353
+ // Give up if the receiver has gone silent for too long (disconnected /
354
+ // closed the app) rather than retransmitting forever.
355
+ if (nowMs() - stalledSinceMs >= SEND_GIVEUP_MS) {
356
+ this.#endSend(friendId, st.fileNumber);
357
+ this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
358
+ return;
359
+ }
360
+ st.lastAckAdvanceMs = nowMs();
361
+ void this.#pumpFrom(friendId, st, st.acked);
362
+ }, Math.ceil(RETRANSMIT_MS / 2));
363
+ if (typeof st.timer.unref === "function")
364
+ st.timer.unref();
365
+ }
216
366
  try {
217
- while (st.transferred < st.size) {
218
- const end = Math.min(st.transferred + MAX_FILE_DATA_SIZE, st.size);
219
- const chunk = st.data.subarray(st.transferred, end);
220
- await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, chunk));
221
- st.transferred = end;
222
- this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.transferred, total: st.size, sending: true });
223
- if (!this.#map(this.#sending, friendId).has(st.fileNumber))
224
- return; // cancelled
367
+ await this.#pumpFrom(friendId, st, st.acked);
368
+ }
369
+ finally {
370
+ st.pumping = false;
371
+ }
372
+ }
373
+ // Send chunks from `from` up to the window edge (acked + window).
374
+ async #pumpFrom(friendId, st, from) {
375
+ const windowEnd = Math.min(st.acked + WINDOW_CHUNKS * MAX_FILE_DATA_SIZE, st.size);
376
+ let off = from;
377
+ while (off < windowEnd) {
378
+ if (!this.#map(this.#sending, friendId).has(st.fileNumber))
379
+ return; // cancelled
380
+ const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
381
+ const chunk = st.data.subarray(off, end);
382
+ try {
383
+ await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, chunk));
384
+ }
385
+ catch {
386
+ return; // transport down; the watchdog will retry from the ack point
225
387
  }
226
- // Empty final chunk marks EOF for the receiver.
227
- await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, new Uint8Array(0)));
228
- this.#map(this.#sending, friendId).delete(st.fileNumber);
229
- this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, sending: true });
388
+ off = end;
230
389
  }
231
- catch {
232
- this.#map(this.#sending, friendId).delete(st.fileNumber);
390
+ }
391
+ #completeSend(friendId, st) {
392
+ this.#endSend(friendId, st.fileNumber);
393
+ this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.size, total: st.size, sending: true });
394
+ this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, sending: true });
395
+ }
396
+ #endSend(friendId, fileNumber) {
397
+ const st = this.#map(this.#sending, friendId).get(fileNumber);
398
+ if (st?.timer) {
399
+ clearInterval(st.timer);
400
+ st.timer = undefined;
233
401
  }
402
+ this.#map(this.#sending, friendId).delete(fileNumber);
403
+ }
404
+ #endRecv(friendId, fileNumber) {
405
+ const st = this.#map(this.#receiving, friendId).get(fileNumber);
406
+ if (st?.ackTimer) {
407
+ clearTimeout(st.ackTimer);
408
+ st.ackTimer = undefined;
409
+ }
410
+ if (st?.reackTimer) {
411
+ clearInterval(st.reackTimer);
412
+ st.reackTimer = undefined;
413
+ }
414
+ this.#map(this.#receiving, friendId).delete(fileNumber);
234
415
  }
235
- /** Drop all transfers for a friend (e.g. on disconnect). */
236
416
  clearFriend(friendId) {
417
+ for (const st of this.#map(this.#sending, friendId).values())
418
+ if (st.timer)
419
+ clearInterval(st.timer);
420
+ for (const st of this.#map(this.#receiving, friendId).values()) {
421
+ if (st.ackTimer)
422
+ clearTimeout(st.ackTimer);
423
+ if (st.reackTimer)
424
+ clearInterval(st.reackTimer);
425
+ }
237
426
  this.#sending.delete(friendId);
238
427
  this.#receiving.delete(friendId);
239
428
  }
package/dist/peer.js CHANGED
@@ -51,6 +51,10 @@ const SELF_ANNOUNCE_INTERVAL_MS = readEnvInt("DECENT_SELF_ANNOUNCE_INTERVAL_MS",
51
51
  // (so neighbours store our address and native peers can find our UDP endpoint)
52
52
  // and toward each not-yet-UDP friend's key (so we find theirs and punch).
53
53
  const DHT_MAINTENANCE_INTERVAL_MS = readEnvInt("DECENT_DHT_MAINTENANCE_INTERVAL_MS", 15000);
54
+ // Hard bound on the DHT routing table. Enough to stay findable + announce on the
55
+ // closest nodes; prevents the aggressive population from growing it (and the hot
56
+ // distance-sort it feeds) without limit — which starved CCTV forwarding over time.
57
+ const MAX_KNOWN_NODES = readEnvInt("DECENT_MAX_KNOWN_NODES", 400);
54
58
  const MAX_SELF_ANNOUNCE_TARGETS = readEnvInt("DECENT_SELF_ANNOUNCE_TARGETS", 16);
55
59
  const SELF_ANNOUNCE_ATTEMPTS = readEnvInt("DECENT_SELF_ANNOUNCE_ATTEMPTS", 3);
56
60
  // Self-announce batch size — number of step1 / step2 requests to fan out
@@ -561,7 +565,12 @@ export class Peer {
561
565
  };
562
566
  }
563
567
  addKnownNodes(nodes) {
564
- this.#knownNodes = dedupeNodes([...nodes, ...this.#knownNodes]);
568
+ // Newest first (dedupeNodes keeps order), then bound the table. Unbounded
569
+ // growth (the aggressive DHT population feeds this on every get_nodes
570
+ // reply) makes the hot distance-sort + memory grow without limit; a few
571
+ // hundred nodes is plenty to stay findable and to announce on the closest.
572
+ const merged = dedupeNodes([...nodes, ...this.#knownNodes]);
573
+ this.#knownNodes = merged.length > MAX_KNOWN_NODES ? merged.slice(0, MAX_KNOWN_NODES) : merged;
565
574
  }
566
575
  knownNodes() {
567
576
  return [...this.#knownNodes];
@@ -3715,12 +3724,23 @@ export class Peer {
3715
3724
  for (const node of this.#knownNodes) {
3716
3725
  if (!node.pk || node.isTcp)
3717
3726
  continue;
3718
- try {
3719
- const pk = base58ToBytes(node.pk);
3720
- if (pk.length === 32)
3721
- withPk.push({ node, pk });
3727
+ // Cache the base58 decode on the node — this runs on every inbound
3728
+ // get_nodes (as a DHT node we answer many), so decoding the whole table
3729
+ // each time was burning CPU that grew with the table and starved the
3730
+ // packet-forwarding loop (CCTV jitter that worsened over time).
3731
+ let pk = node.pkBytes;
3732
+ if (!pk) {
3733
+ try {
3734
+ pk = base58ToBytes(node.pk);
3735
+ }
3736
+ catch {
3737
+ continue;
3738
+ }
3739
+ if (pk.length !== 32)
3740
+ continue;
3741
+ node.pkBytes = pk;
3722
3742
  }
3723
- catch { /* skip malformed */ }
3743
+ withPk.push({ node, pk });
3724
3744
  }
3725
3745
  withPk.sort((a, b) => xorCloser(target, a.pk, b.pk));
3726
3746
  return withPk.slice(0, limit).map((e) => e.node);
@@ -11,6 +11,10 @@ export type NetworkNode = {
11
11
  * relays this way in DHT-PK extras.
12
12
  */
13
13
  isTcp?: boolean;
14
+ /** Decoded 32-byte form of `pk`, cached lazily on first use so the hot
15
+ * DHT-distance sort (#closestKnownNodes, run on every get_nodes we answer)
16
+ * doesn't base58-decode the whole table on every call. */
17
+ pkBytes?: Uint8Array;
14
18
  };
15
19
  export type PeerOptions = {
16
20
  keyFile: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.49",
3
+ "version": "0.1.50",
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",