@decentnetwork/peer 0.1.49 → 0.1.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compat/filetransfer.d.ts +4 -7
- package/dist/compat/filetransfer.js +247 -58
- package/dist/peer.js +47 -8
- package/dist/types/peer.d.ts +4 -0
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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) −
|
|
19
|
-
export const MAX_FILE_DATA_SIZE =
|
|
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 <
|
|
94
|
+
if (p.length < 5)
|
|
79
95
|
return undefined;
|
|
80
|
-
return { fileNumber: p[0], data: p.slice(
|
|
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 = {
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.#
|
|
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.#
|
|
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 (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
|
@@ -189,6 +193,11 @@ export class Peer {
|
|
|
189
193
|
#persistSeq = 0; // makes atomic friend-store temp filenames unique per write
|
|
190
194
|
#cookieSymmetricKey;
|
|
191
195
|
#friendSessions = new Map();
|
|
196
|
+
/** source "ip:port" → friendId, so an inbound crypto-data packet is decrypted
|
|
197
|
+
* against the right session FIRST instead of scanning every friend (O(N) nacl
|
|
198
|
+
* ops/packet starved high-rate CCTV forwarding). Self-correcting: stale
|
|
199
|
+
* entries just miss and fall back to the scan. */
|
|
200
|
+
#cryptoEndpointIndex = new Map();
|
|
192
201
|
#express;
|
|
193
202
|
#expressPollTimer;
|
|
194
203
|
/**
|
|
@@ -561,7 +570,12 @@ export class Peer {
|
|
|
561
570
|
};
|
|
562
571
|
}
|
|
563
572
|
addKnownNodes(nodes) {
|
|
564
|
-
|
|
573
|
+
// Newest first (dedupeNodes keeps order), then bound the table. Unbounded
|
|
574
|
+
// growth (the aggressive DHT population feeds this on every get_nodes
|
|
575
|
+
// reply) makes the hot distance-sort + memory grow without limit; a few
|
|
576
|
+
// hundred nodes is plenty to stay findable and to announce on the closest.
|
|
577
|
+
const merged = dedupeNodes([...nodes, ...this.#knownNodes]);
|
|
578
|
+
this.#knownNodes = merged.length > MAX_KNOWN_NODES ? merged.slice(0, MAX_KNOWN_NODES) : merged;
|
|
565
579
|
}
|
|
566
580
|
knownNodes() {
|
|
567
581
|
return [...this.#knownNodes];
|
|
@@ -1540,8 +1554,17 @@ export class Peer {
|
|
|
1540
1554
|
return;
|
|
1541
1555
|
}
|
|
1542
1556
|
if (packet[0] === NET_PACKET_CRYPTO_DATA) {
|
|
1543
|
-
|
|
1544
|
-
|
|
1557
|
+
// Demux by source endpoint: try the session last seen here FIRST so a
|
|
1558
|
+
// high-rate stream (CCTV) isn't decrypted against every friend session
|
|
1559
|
+
// (O(friends) nacl ops per packet, which starved forwarding). Falls back
|
|
1560
|
+
// to scanning all sessions for a new/changed endpoint, then caches the
|
|
1561
|
+
// match. A stale hint just misses and falls through harmlessly.
|
|
1562
|
+
const epKey = `${remote.address}:${remote.port}`;
|
|
1563
|
+
const hinted = this.#cryptoEndpointIndex.get(epKey);
|
|
1564
|
+
const ids = hinted !== undefined ? [hinted, ...this.#friendSessions.keys()] : this.#friendSessions.keys();
|
|
1565
|
+
for (const friendId of ids) {
|
|
1566
|
+
const state = this.#friendSessions.get(friendId);
|
|
1567
|
+
if (!state || !state.sessionSharedKey || !state.peerBaseNonce) {
|
|
1545
1568
|
continue;
|
|
1546
1569
|
}
|
|
1547
1570
|
const opened = openCryptoDataPacket(packet, {
|
|
@@ -1551,6 +1574,11 @@ export class Peer {
|
|
|
1551
1574
|
if (!opened) {
|
|
1552
1575
|
continue;
|
|
1553
1576
|
}
|
|
1577
|
+
if (hinted !== friendId) {
|
|
1578
|
+
if (this.#cryptoEndpointIndex.size > 4096)
|
|
1579
|
+
this.#cryptoEndpointIndex.clear();
|
|
1580
|
+
this.#cryptoEndpointIndex.set(epKey, friendId);
|
|
1581
|
+
}
|
|
1554
1582
|
// De-duplicate — but ONLY for dual-sent channels. The dual-send path
|
|
1555
1583
|
// delivers a lossless packet over BOTH UDP and relay with the SAME
|
|
1556
1584
|
// packet number, so without this guard a chat message is dispatched
|
|
@@ -3715,12 +3743,23 @@ export class Peer {
|
|
|
3715
3743
|
for (const node of this.#knownNodes) {
|
|
3716
3744
|
if (!node.pk || node.isTcp)
|
|
3717
3745
|
continue;
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3746
|
+
// Cache the base58 decode on the node — this runs on every inbound
|
|
3747
|
+
// get_nodes (as a DHT node we answer many), so decoding the whole table
|
|
3748
|
+
// each time was burning CPU that grew with the table and starved the
|
|
3749
|
+
// packet-forwarding loop (CCTV jitter that worsened over time).
|
|
3750
|
+
let pk = node.pkBytes;
|
|
3751
|
+
if (!pk) {
|
|
3752
|
+
try {
|
|
3753
|
+
pk = base58ToBytes(node.pk);
|
|
3754
|
+
}
|
|
3755
|
+
catch {
|
|
3756
|
+
continue;
|
|
3757
|
+
}
|
|
3758
|
+
if (pk.length !== 32)
|
|
3759
|
+
continue;
|
|
3760
|
+
node.pkBytes = pk;
|
|
3722
3761
|
}
|
|
3723
|
-
|
|
3762
|
+
withPk.push({ node, pk });
|
|
3724
3763
|
}
|
|
3725
3764
|
withPk.sort((a, b) => xorCloser(target, a.pk, b.pk));
|
|
3726
3765
|
return withPk.slice(0, limit).map((e) => e.node);
|
package/dist/types/peer.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.51",
|
|
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",
|