@dropgate/core 2.2.1 → 3.0.1

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/p2p/index.js CHANGED
@@ -1,18 +1,16 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
1
5
  // src/errors.ts
2
6
  var DropgateError = class extends Error {
3
7
  constructor(message, opts = {}) {
4
- super(message);
8
+ super(message, opts.cause !== void 0 ? { cause: opts.cause } : void 0);
9
+ __publicField(this, "code");
10
+ __publicField(this, "details");
5
11
  this.name = this.constructor.name;
6
12
  this.code = opts.code || "DROPGATE_ERROR";
7
13
  this.details = opts.details;
8
- if (opts.cause !== void 0) {
9
- Object.defineProperty(this, "cause", {
10
- value: opts.cause,
11
- writable: false,
12
- enumerable: false,
13
- configurable: true
14
- });
15
- }
16
14
  }
17
15
  };
18
16
  var DropgateValidationError = class extends DropgateError {
@@ -144,13 +142,57 @@ async function createPeerWithRetries(opts) {
144
142
  throw lastError || new DropgateNetworkError("Could not establish PeerJS connection.");
145
143
  }
146
144
 
145
+ // src/p2p/protocol.ts
146
+ var P2P_PROTOCOL_VERSION = 3;
147
+ function isP2PMessage(value) {
148
+ if (!value || typeof value !== "object") return false;
149
+ const msg = value;
150
+ return typeof msg.t === "string" && [
151
+ "hello",
152
+ "file_list",
153
+ "meta",
154
+ "ready",
155
+ "chunk",
156
+ "chunk_ack",
157
+ "file_end",
158
+ "file_end_ack",
159
+ "end",
160
+ "end_ack",
161
+ "ping",
162
+ "pong",
163
+ "error",
164
+ "cancelled",
165
+ "resume",
166
+ "resume_ack"
167
+ ].includes(msg.t);
168
+ }
169
+ function isProtocolCompatible(senderVersion, receiverVersion) {
170
+ return senderVersion === receiverVersion;
171
+ }
172
+ var P2P_CHUNK_SIZE = 64 * 1024;
173
+ var P2P_MAX_UNACKED_CHUNKS = 32;
174
+ var P2P_END_ACK_TIMEOUT_MS = 15e3;
175
+ var P2P_END_ACK_RETRIES = 3;
176
+ var P2P_END_ACK_RETRY_DELAY_MS = 100;
177
+ var P2P_CLOSE_GRACE_PERIOD_MS = 2e3;
178
+
147
179
  // src/p2p/send.ts
180
+ var P2P_UNACKED_CHUNK_TIMEOUT_MS = 3e4;
148
181
  function generateSessionId() {
149
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
150
- return crypto.randomUUID();
151
- }
152
- return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
182
+ return crypto.randomUUID();
153
183
  }
184
+ var ALLOWED_TRANSITIONS = {
185
+ initializing: ["listening", "closed"],
186
+ listening: ["handshaking", "closed", "cancelled"],
187
+ handshaking: ["negotiating", "closed", "cancelled"],
188
+ negotiating: ["transferring", "closed", "cancelled"],
189
+ transferring: ["finishing", "closed", "cancelled"],
190
+ finishing: ["awaiting_ack", "closed", "cancelled"],
191
+ awaiting_ack: ["completed", "closed", "cancelled"],
192
+ completed: ["closed"],
193
+ cancelled: ["closed"],
194
+ closed: []
195
+ };
154
196
  async function startP2PSend(opts) {
155
197
  const {
156
198
  file,
@@ -164,21 +206,27 @@ async function startP2PSend(opts) {
164
206
  codeGenerator,
165
207
  cryptoObj,
166
208
  maxAttempts = 4,
167
- chunkSize = 256 * 1024,
168
- endAckTimeoutMs = 15e3,
209
+ chunkSize = P2P_CHUNK_SIZE,
210
+ endAckTimeoutMs = P2P_END_ACK_TIMEOUT_MS,
169
211
  bufferHighWaterMark = 8 * 1024 * 1024,
170
212
  bufferLowWaterMark = 2 * 1024 * 1024,
171
213
  heartbeatIntervalMs = 5e3,
214
+ chunkAcknowledgments = true,
215
+ maxUnackedChunks = P2P_MAX_UNACKED_CHUNKS,
172
216
  onCode,
173
217
  onStatus,
174
218
  onProgress,
175
219
  onComplete,
176
220
  onError,
177
221
  onDisconnect,
178
- onCancel
222
+ onCancel,
223
+ onConnectionHealth
179
224
  } = opts;
180
- if (!file) {
181
- throw new DropgateValidationError("File is missing.");
225
+ const files = Array.isArray(file) ? file : [file];
226
+ const isMultiFile = files.length > 1;
227
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
228
+ if (!files.length) {
229
+ throw new DropgateValidationError("At least one file is required.");
182
230
  }
183
231
  if (!Peer) {
184
232
  throw new DropgateValidationError(
@@ -214,21 +262,39 @@ async function startP2PSend(opts) {
214
262
  let activeConn = null;
215
263
  let sentBytes = 0;
216
264
  let heartbeatTimer = null;
265
+ let healthCheckTimer = null;
266
+ let lastActivityTime = Date.now();
267
+ const unackedChunks = /* @__PURE__ */ new Map();
268
+ let nextSeq = 0;
269
+ let ackResolvers = [];
270
+ let transferEverStarted = false;
271
+ const connectionAttempts = [];
272
+ const MAX_CONNECTION_ATTEMPTS = 10;
273
+ const CONNECTION_RATE_WINDOW_MS = 1e4;
274
+ const transitionTo = (newState) => {
275
+ if (!ALLOWED_TRANSITIONS[state].includes(newState)) {
276
+ console.warn(`[P2P Send] Invalid state transition: ${state} -> ${newState}`);
277
+ return false;
278
+ }
279
+ state = newState;
280
+ return true;
281
+ };
217
282
  const reportProgress = (data) => {
218
- const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
283
+ if (isStopped()) return;
284
+ const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : totalSize;
219
285
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
220
286
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
221
287
  onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
222
288
  };
223
289
  const safeError = (err) => {
224
290
  if (state === "closed" || state === "completed" || state === "cancelled") return;
225
- state = "closed";
291
+ transitionTo("closed");
226
292
  onError?.(err);
227
293
  cleanup();
228
294
  };
229
295
  const safeComplete = () => {
230
- if (state !== "finishing") return;
231
- state = "completed";
296
+ if (state !== "awaiting_ack" && state !== "finishing") return;
297
+ transitionTo("completed");
232
298
  onComplete?.();
233
299
  cleanup();
234
300
  };
@@ -237,6 +303,13 @@ async function startP2PSend(opts) {
237
303
  clearInterval(heartbeatTimer);
238
304
  heartbeatTimer = null;
239
305
  }
306
+ if (healthCheckTimer) {
307
+ clearInterval(healthCheckTimer);
308
+ healthCheckTimer = null;
309
+ }
310
+ ackResolvers.forEach((resolve) => resolve());
311
+ ackResolvers = [];
312
+ unackedChunks.clear();
240
313
  if (typeof window !== "undefined") {
241
314
  window.removeEventListener("beforeunload", handleUnload);
242
315
  }
@@ -261,8 +334,12 @@ async function startP2PSend(opts) {
261
334
  }
262
335
  const stop = () => {
263
336
  if (state === "closed" || state === "cancelled") return;
264
- const wasActive = state === "transferring" || state === "finishing";
265
- state = "cancelled";
337
+ if (state === "completed") {
338
+ cleanup();
339
+ return;
340
+ }
341
+ const wasActive = state === "transferring" || state === "finishing" || state === "awaiting_ack";
342
+ transitionTo("cancelled");
266
343
  try {
267
344
  if (activeConn && activeConn.open) {
268
345
  activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
@@ -275,8 +352,114 @@ async function startP2PSend(opts) {
275
352
  cleanup();
276
353
  };
277
354
  const isStopped = () => state === "closed" || state === "cancelled";
355
+ const startHealthMonitoring = (conn) => {
356
+ if (!onConnectionHealth) return;
357
+ healthCheckTimer = setInterval(() => {
358
+ if (isStopped()) return;
359
+ const dc = conn._dc;
360
+ if (!dc) return;
361
+ const health = {
362
+ iceConnectionState: dc.readyState === "open" ? "connected" : "disconnected",
363
+ bufferedAmount: dc.bufferedAmount,
364
+ lastActivityMs: Date.now() - lastActivityTime
365
+ };
366
+ onConnectionHealth(health);
367
+ }, 2e3);
368
+ };
369
+ const handleChunkAck = (msg) => {
370
+ lastActivityTime = Date.now();
371
+ unackedChunks.delete(msg.seq);
372
+ reportProgress({ received: msg.received, total: totalSize });
373
+ const resolver = ackResolvers.shift();
374
+ if (resolver) resolver();
375
+ };
376
+ const waitForAck = () => {
377
+ return new Promise((resolve) => {
378
+ ackResolvers.push(resolve);
379
+ });
380
+ };
381
+ const sendChunk = async (conn, data, offset, fileTotal) => {
382
+ if (chunkAcknowledgments) {
383
+ while (unackedChunks.size >= maxUnackedChunks) {
384
+ const now = Date.now();
385
+ for (const [_seq, chunk] of unackedChunks) {
386
+ if (now - chunk.sentAt > P2P_UNACKED_CHUNK_TIMEOUT_MS) {
387
+ throw new DropgateNetworkError("Receiver stopped acknowledging chunks");
388
+ }
389
+ }
390
+ await Promise.race([
391
+ waitForAck(),
392
+ sleep(1e3)
393
+ // Timeout to prevent deadlock
394
+ ]);
395
+ if (isStopped()) return;
396
+ }
397
+ }
398
+ const seq = nextSeq++;
399
+ if (chunkAcknowledgments) {
400
+ unackedChunks.set(seq, { offset, size: data.byteLength, sentAt: Date.now() });
401
+ }
402
+ conn.send({ t: "chunk", seq, offset, size: data.byteLength, total: fileTotal ?? totalSize });
403
+ conn.send(data);
404
+ sentBytes += data.byteLength;
405
+ const dc = conn._dc;
406
+ if (dc && bufferHighWaterMark > 0) {
407
+ while (dc.bufferedAmount > bufferHighWaterMark) {
408
+ await new Promise((resolve) => {
409
+ const fallback = setTimeout(resolve, 60);
410
+ try {
411
+ dc.addEventListener(
412
+ "bufferedamountlow",
413
+ () => {
414
+ clearTimeout(fallback);
415
+ resolve();
416
+ },
417
+ { once: true }
418
+ );
419
+ } catch {
420
+ }
421
+ });
422
+ if (isStopped()) return;
423
+ }
424
+ }
425
+ };
426
+ const waitForEndAck = async (conn, ackPromise) => {
427
+ const baseTimeout = endAckTimeoutMs;
428
+ for (let attempt = 0; attempt < P2P_END_ACK_RETRIES; attempt++) {
429
+ conn.send({ t: "end", attempt });
430
+ const timeout = baseTimeout * Math.pow(1.5, attempt);
431
+ const result = await Promise.race([
432
+ ackPromise,
433
+ sleep(timeout).then(() => null)
434
+ ]);
435
+ if (result && result.t === "end_ack") {
436
+ return result;
437
+ }
438
+ if (isStopped()) {
439
+ throw new DropgateNetworkError("Connection closed during completion.");
440
+ }
441
+ }
442
+ throw new DropgateNetworkError("Receiver did not confirm completion after retries.");
443
+ };
278
444
  peer.on("connection", (conn) => {
279
- if (state === "closed") return;
445
+ if (isStopped()) return;
446
+ const now = Date.now();
447
+ while (connectionAttempts.length > 0 && connectionAttempts[0] < now - CONNECTION_RATE_WINDOW_MS) {
448
+ connectionAttempts.shift();
449
+ }
450
+ if (connectionAttempts.length >= MAX_CONNECTION_ATTEMPTS) {
451
+ console.warn("[P2P Send] Connection rate limit exceeded, rejecting connection");
452
+ try {
453
+ conn.send({ t: "error", message: "Too many connection attempts. Please wait." });
454
+ } catch {
455
+ }
456
+ try {
457
+ conn.close();
458
+ } catch {
459
+ }
460
+ return;
461
+ }
462
+ connectionAttempts.push(now);
280
463
  if (activeConn) {
281
464
  const isOldConnOpen = activeConn.open !== false;
282
465
  if (isOldConnOpen && state === "transferring") {
@@ -295,8 +478,21 @@ async function startP2PSend(opts) {
295
478
  } catch {
296
479
  }
297
480
  activeConn = null;
481
+ if (transferEverStarted) {
482
+ try {
483
+ conn.send({ t: "error", message: "Transfer already started with another receiver. Cannot reconnect." });
484
+ } catch {
485
+ }
486
+ try {
487
+ conn.close();
488
+ } catch {
489
+ }
490
+ return;
491
+ }
298
492
  state = "listening";
299
493
  sentBytes = 0;
494
+ nextSeq = 0;
495
+ unackedChunks.clear();
300
496
  } else {
301
497
  try {
302
498
  conn.send({ t: "error", message: "Another receiver is already connected." });
@@ -310,60 +506,98 @@ async function startP2PSend(opts) {
310
506
  }
311
507
  }
312
508
  activeConn = conn;
313
- state = "negotiating";
314
- onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
509
+ transitionTo("handshaking");
510
+ if (!isStopped()) onStatus?.({ phase: "connected", message: "Receiver connected." });
511
+ lastActivityTime = Date.now();
512
+ let helloResolve = null;
315
513
  let readyResolve = null;
316
- let ackResolve = null;
514
+ let endAckResolve = null;
515
+ let fileEndAckResolve = null;
516
+ const helloPromise = new Promise((resolve) => {
517
+ helloResolve = resolve;
518
+ });
317
519
  const readyPromise = new Promise((resolve) => {
318
520
  readyResolve = resolve;
319
521
  });
320
- const ackPromise = new Promise((resolve) => {
321
- ackResolve = resolve;
522
+ const endAckPromise = new Promise((resolve) => {
523
+ endAckResolve = resolve;
322
524
  });
323
525
  conn.on("data", (data) => {
324
- if (!data || typeof data !== "object" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
526
+ lastActivityTime = Date.now();
527
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
325
528
  return;
326
529
  }
530
+ if (!isP2PMessage(data)) return;
327
531
  const msg = data;
328
- if (!msg.t) return;
329
- if (msg.t === "ready") {
330
- onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
331
- readyResolve?.();
332
- return;
333
- }
334
- if (msg.t === "progress") {
335
- reportProgress({ received: msg.received || 0, total: msg.total || 0 });
336
- return;
337
- }
338
- if (msg.t === "ack" && msg.phase === "end") {
339
- ackResolve?.(msg);
340
- return;
341
- }
342
- if (msg.t === "pong") {
343
- return;
344
- }
345
- if (msg.t === "error") {
346
- safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
347
- return;
348
- }
349
- if (msg.t === "cancelled") {
350
- if (state === "cancelled" || state === "closed" || state === "completed") return;
351
- state = "cancelled";
352
- onCancel?.({ cancelledBy: "receiver", message: msg.message });
353
- cleanup();
532
+ switch (msg.t) {
533
+ case "hello":
534
+ helloResolve?.(msg.protocolVersion);
535
+ break;
536
+ case "ready":
537
+ if (!isStopped()) onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
538
+ readyResolve?.();
539
+ break;
540
+ case "chunk_ack":
541
+ handleChunkAck(msg);
542
+ break;
543
+ case "file_end_ack":
544
+ fileEndAckResolve?.(msg);
545
+ break;
546
+ case "end_ack":
547
+ endAckResolve?.(msg);
548
+ break;
549
+ case "pong":
550
+ break;
551
+ case "error":
552
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
553
+ break;
554
+ case "cancelled":
555
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
556
+ transitionTo("cancelled");
557
+ onCancel?.({ cancelledBy: "receiver", message: msg.reason });
558
+ cleanup();
559
+ break;
354
560
  }
355
561
  });
356
562
  conn.on("open", async () => {
357
563
  try {
358
564
  if (isStopped()) return;
565
+ startHealthMonitoring(conn);
566
+ conn.send({
567
+ t: "hello",
568
+ protocolVersion: P2P_PROTOCOL_VERSION,
569
+ sessionId
570
+ });
571
+ const receiverVersion = await Promise.race([
572
+ helloPromise,
573
+ sleep(1e4).then(() => null)
574
+ ]);
575
+ if (isStopped()) return;
576
+ if (receiverVersion === null) {
577
+ throw new DropgateNetworkError("Receiver did not respond to handshake.");
578
+ } else if (receiverVersion !== P2P_PROTOCOL_VERSION) {
579
+ throw new DropgateNetworkError(
580
+ `Protocol version mismatch: sender v${P2P_PROTOCOL_VERSION}, receiver v${receiverVersion}`
581
+ );
582
+ }
583
+ transitionTo("negotiating");
584
+ if (!isStopped()) onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
585
+ if (isMultiFile) {
586
+ conn.send({
587
+ t: "file_list",
588
+ fileCount: files.length,
589
+ files: files.map((f) => ({ name: f.name, size: f.size, mime: f.type || "application/octet-stream" })),
590
+ totalSize
591
+ });
592
+ }
359
593
  conn.send({
360
594
  t: "meta",
361
595
  sessionId,
362
- name: file.name,
363
- size: file.size,
364
- mime: file.type || "application/octet-stream"
596
+ name: files[0].name,
597
+ size: files[0].size,
598
+ mime: files[0].type || "application/octet-stream",
599
+ ...isMultiFile ? { fileIndex: 0 } : {}
365
600
  });
366
- const total = file.size;
367
601
  const dc = conn._dc;
368
602
  if (dc && Number.isFinite(bufferLowWaterMark)) {
369
603
  try {
@@ -375,56 +609,61 @@ async function startP2PSend(opts) {
375
609
  if (isStopped()) return;
376
610
  if (heartbeatIntervalMs > 0) {
377
611
  heartbeatTimer = setInterval(() => {
378
- if (state === "transferring" || state === "finishing") {
612
+ if (state === "transferring" || state === "finishing" || state === "awaiting_ack") {
379
613
  try {
380
- conn.send({ t: "ping" });
614
+ conn.send({ t: "ping", timestamp: Date.now() });
381
615
  } catch {
382
616
  }
383
617
  }
384
618
  }, heartbeatIntervalMs);
385
619
  }
386
- state = "transferring";
387
- for (let offset = 0; offset < total; offset += chunkSize) {
388
- if (isStopped()) return;
389
- const slice = file.slice(offset, offset + chunkSize);
390
- const buf = await slice.arrayBuffer();
620
+ transitionTo("transferring");
621
+ transferEverStarted = true;
622
+ let overallSentBytes = 0;
623
+ for (let fi = 0; fi < files.length; fi++) {
624
+ const currentFile = files[fi];
625
+ if (isMultiFile && fi > 0) {
626
+ conn.send({
627
+ t: "meta",
628
+ sessionId,
629
+ name: currentFile.name,
630
+ size: currentFile.size,
631
+ mime: currentFile.type || "application/octet-stream",
632
+ fileIndex: fi
633
+ });
634
+ }
635
+ for (let offset = 0; offset < currentFile.size; offset += chunkSize) {
636
+ if (isStopped()) return;
637
+ const slice = currentFile.slice(offset, offset + chunkSize);
638
+ const buf = await slice.arrayBuffer();
639
+ if (isStopped()) return;
640
+ await sendChunk(conn, buf, offset, currentFile.size);
641
+ overallSentBytes += buf.byteLength;
642
+ reportProgress({ received: overallSentBytes, total: totalSize });
643
+ }
391
644
  if (isStopped()) return;
392
- conn.send(buf);
393
- sentBytes += buf.byteLength;
394
- if (dc) {
395
- while (dc.bufferedAmount > bufferHighWaterMark) {
396
- await new Promise((resolve) => {
397
- const fallback = setTimeout(resolve, 60);
398
- try {
399
- dc.addEventListener(
400
- "bufferedamountlow",
401
- () => {
402
- clearTimeout(fallback);
403
- resolve();
404
- },
405
- { once: true }
406
- );
407
- } catch {
408
- }
409
- });
645
+ if (isMultiFile) {
646
+ const fileEndAckPromise = new Promise((resolve) => {
647
+ fileEndAckResolve = resolve;
648
+ });
649
+ conn.send({ t: "file_end", fileIndex: fi });
650
+ const feAck = await Promise.race([
651
+ fileEndAckPromise,
652
+ sleep(endAckTimeoutMs).then(() => null)
653
+ ]);
654
+ if (isStopped()) return;
655
+ if (!feAck) {
656
+ throw new DropgateNetworkError(`Receiver did not confirm receipt of file ${fi + 1}/${files.length}.`);
410
657
  }
411
658
  }
412
659
  }
413
660
  if (isStopped()) return;
414
- state = "finishing";
415
- conn.send({ t: "end" });
416
- const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
417
- const ackResult = await Promise.race([
418
- ackPromise,
419
- sleep(ackTimeoutMs || 15e3).catch(() => null)
420
- ]);
661
+ transitionTo("finishing");
662
+ transitionTo("awaiting_ack");
663
+ const ackResult = await waitForEndAck(conn, endAckPromise);
421
664
  if (isStopped()) return;
422
- if (!ackResult || typeof ackResult !== "object") {
423
- throw new DropgateNetworkError("Receiver did not confirm completion.");
424
- }
425
- const ackData = ackResult;
426
- const ackTotal = Number(ackData.total) || file.size;
427
- const ackReceived = Number(ackData.received) || 0;
665
+ const ackTotal = Number(ackResult.total) || totalSize;
666
+ const ackReceived = Number(ackResult.received) || 0;
428
667
  if (ackTotal && ackReceived < ackTotal) {
429
668
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
430
669
  }
@@ -442,14 +681,24 @@ async function startP2PSend(opts) {
442
681
  cleanup();
443
682
  return;
444
683
  }
684
+ if (state === "awaiting_ack") {
685
+ setTimeout(() => {
686
+ if (state === "awaiting_ack") {
687
+ safeError(new DropgateNetworkError("Connection closed while awaiting confirmation."));
688
+ }
689
+ }, P2P_CLOSE_GRACE_PERIOD_MS);
690
+ return;
691
+ }
445
692
  if (state === "transferring" || state === "finishing") {
446
- state = "cancelled";
693
+ transitionTo("cancelled");
447
694
  onCancel?.({ cancelledBy: "receiver" });
448
695
  cleanup();
449
696
  } else {
450
697
  activeConn = null;
451
698
  state = "listening";
452
699
  sentBytes = 0;
700
+ nextSeq = 0;
701
+ unackedChunks.clear();
453
702
  onDisconnect?.();
454
703
  }
455
704
  });
@@ -469,6 +718,16 @@ async function startP2PSend(opts) {
469
718
  }
470
719
 
471
720
  // src/p2p/receive.ts
721
+ var ALLOWED_TRANSITIONS2 = {
722
+ initializing: ["connecting", "closed"],
723
+ connecting: ["handshaking", "closed", "cancelled"],
724
+ handshaking: ["negotiating", "closed", "cancelled"],
725
+ negotiating: ["transferring", "closed", "cancelled"],
726
+ transferring: ["completed", "closed", "cancelled"],
727
+ completed: ["closed"],
728
+ cancelled: ["closed"],
729
+ closed: []
730
+ };
472
731
  async function startP2PReceive(opts) {
473
732
  const {
474
733
  code,
@@ -485,6 +744,8 @@ async function startP2PReceive(opts) {
485
744
  onMeta,
486
745
  onData,
487
746
  onProgress,
747
+ onFileStart,
748
+ onFileEnd,
488
749
  onComplete,
489
750
  onError,
490
751
  onDisconnect,
@@ -522,11 +783,26 @@ async function startP2PReceive(opts) {
522
783
  let total = 0;
523
784
  let received = 0;
524
785
  let currentSessionId = null;
525
- let lastProgressSentAt = 0;
526
- const progressIntervalMs = 120;
527
786
  let writeQueue = Promise.resolve();
528
787
  let watchdogTimer = null;
529
788
  let activeConn = null;
789
+ let pendingChunk = null;
790
+ let fileList = null;
791
+ let currentFileReceived = 0;
792
+ let totalReceivedAllFiles = 0;
793
+ let expectedChunkSeq = 0;
794
+ let writeQueueDepth = 0;
795
+ const MAX_WRITE_QUEUE_DEPTH = 100;
796
+ const MAX_FILE_COUNT = 1e4;
797
+ const transitionTo = (newState) => {
798
+ if (!ALLOWED_TRANSITIONS2[state].includes(newState)) {
799
+ console.warn(`[P2P Receive] Invalid state transition: ${state} -> ${newState}`);
800
+ return false;
801
+ }
802
+ state = newState;
803
+ return true;
804
+ };
805
+ const isStopped = () => state === "closed" || state === "cancelled";
530
806
  const resetWatchdog = () => {
531
807
  if (watchdogTimeoutMs <= 0) return;
532
808
  if (watchdogTimer) {
@@ -546,15 +822,14 @@ async function startP2PReceive(opts) {
546
822
  };
547
823
  const safeError = (err) => {
548
824
  if (state === "closed" || state === "completed" || state === "cancelled") return;
549
- state = "closed";
825
+ transitionTo("closed");
550
826
  onError?.(err);
551
827
  cleanup();
552
828
  };
553
829
  const safeComplete = (completeData) => {
554
830
  if (state !== "transferring") return;
555
- state = "completed";
831
+ transitionTo("completed");
556
832
  onComplete?.(completeData);
557
- cleanup();
558
833
  };
559
834
  const cleanup = () => {
560
835
  clearWatchdog();
@@ -578,11 +853,15 @@ async function startP2PReceive(opts) {
578
853
  }
579
854
  const stop = () => {
580
855
  if (state === "closed" || state === "cancelled") return;
856
+ if (state === "completed") {
857
+ cleanup();
858
+ return;
859
+ }
581
860
  const wasActive = state === "transferring";
582
- state = "cancelled";
861
+ transitionTo("cancelled");
583
862
  try {
584
863
  if (activeConn && activeConn.open) {
585
- activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
864
+ activeConn.send({ t: "cancelled", reason: "Receiver cancelled the transfer." });
586
865
  }
587
866
  } catch {
588
867
  }
@@ -591,23 +870,122 @@ async function startP2PReceive(opts) {
591
870
  }
592
871
  cleanup();
593
872
  };
873
+ const sendChunkAck = (conn, seq) => {
874
+ try {
875
+ conn.send({ t: "chunk_ack", seq, received });
876
+ } catch {
877
+ }
878
+ };
594
879
  peer.on("error", (err) => {
595
880
  safeError(err);
596
881
  });
597
882
  peer.on("open", () => {
598
- state = "connecting";
883
+ transitionTo("connecting");
599
884
  const conn = peer.connect(normalizedCode, { reliable: true });
600
885
  activeConn = conn;
601
886
  conn.on("open", () => {
602
- state = "negotiating";
603
- onStatus?.({ phase: "connected", message: "Waiting for file details..." });
887
+ transitionTo("handshaking");
888
+ onStatus?.({ phase: "connected", message: "Connected." });
889
+ conn.send({
890
+ t: "hello",
891
+ protocolVersion: P2P_PROTOCOL_VERSION,
892
+ sessionId: ""
893
+ });
604
894
  });
605
895
  conn.on("data", async (data) => {
606
896
  try {
607
- resetWatchdog();
608
- if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
609
- const msg = data;
610
- if (msg.t === "meta") {
897
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || typeof Blob !== "undefined" && data instanceof Blob) {
898
+ if (state !== "transferring") {
899
+ throw new DropgateValidationError(
900
+ "Received binary data before transfer was accepted. Possible malicious sender."
901
+ );
902
+ }
903
+ resetWatchdog();
904
+ if (writeQueueDepth >= MAX_WRITE_QUEUE_DEPTH) {
905
+ throw new DropgateNetworkError("Write queue overflow - receiver cannot keep up");
906
+ }
907
+ let bufPromise;
908
+ if (data instanceof ArrayBuffer) {
909
+ bufPromise = Promise.resolve(new Uint8Array(data));
910
+ } else if (ArrayBuffer.isView(data)) {
911
+ bufPromise = Promise.resolve(
912
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
913
+ );
914
+ } else if (typeof Blob !== "undefined" && data instanceof Blob) {
915
+ bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
916
+ } else {
917
+ return;
918
+ }
919
+ const chunkSeq = pendingChunk?.seq ?? -1;
920
+ const expectedSize = pendingChunk?.size;
921
+ pendingChunk = null;
922
+ writeQueueDepth++;
923
+ writeQueue = writeQueue.then(async () => {
924
+ const buf = await bufPromise;
925
+ if (expectedSize !== void 0 && buf.byteLength !== expectedSize) {
926
+ throw new DropgateValidationError(
927
+ `Chunk size mismatch: expected ${expectedSize}, got ${buf.byteLength}`
928
+ );
929
+ }
930
+ const newReceived = received + buf.byteLength;
931
+ if (total > 0 && newReceived > total) {
932
+ throw new DropgateValidationError(
933
+ `Received more data than expected: ${newReceived} > ${total}`
934
+ );
935
+ }
936
+ if (onData) {
937
+ await onData(buf);
938
+ }
939
+ received += buf.byteLength;
940
+ currentFileReceived += buf.byteLength;
941
+ const progressReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
942
+ const progressTotal = fileList ? fileList.totalSize : total;
943
+ const percent = progressTotal ? Math.min(100, progressReceived / progressTotal * 100) : 0;
944
+ if (!isStopped()) onProgress?.({ processedBytes: progressReceived, totalBytes: progressTotal, percent });
945
+ if (chunkSeq >= 0) {
946
+ sendChunkAck(conn, chunkSeq);
947
+ }
948
+ }).catch((err) => {
949
+ try {
950
+ conn.send({
951
+ t: "error",
952
+ message: err?.message || "Receiver write failed."
953
+ });
954
+ } catch {
955
+ }
956
+ safeError(err);
957
+ }).finally(() => {
958
+ writeQueueDepth--;
959
+ });
960
+ return;
961
+ }
962
+ if (!isP2PMessage(data)) return;
963
+ const msg = data;
964
+ switch (msg.t) {
965
+ case "hello":
966
+ currentSessionId = msg.sessionId || null;
967
+ transitionTo("negotiating");
968
+ onStatus?.({ phase: "waiting", message: "Waiting for file details..." });
969
+ break;
970
+ case "file_list": {
971
+ const fileListMsg = msg;
972
+ if (fileListMsg.fileCount > MAX_FILE_COUNT) {
973
+ throw new DropgateValidationError(`Too many files: ${fileListMsg.fileCount}`);
974
+ }
975
+ const sumSize = fileListMsg.files.reduce((sum, f) => sum + f.size, 0);
976
+ if (sumSize !== fileListMsg.totalSize) {
977
+ throw new DropgateValidationError(
978
+ `File list size mismatch: declared ${fileListMsg.totalSize}, actual sum ${sumSize}`
979
+ );
980
+ }
981
+ fileList = fileListMsg;
982
+ total = fileListMsg.totalSize;
983
+ break;
984
+ }
985
+ case "meta": {
986
+ if (state !== "negotiating" && !(state === "transferring" && fileList)) {
987
+ return;
988
+ }
611
989
  if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
612
990
  try {
613
991
  conn.send({ t: "error", message: "Busy with another session." });
@@ -619,40 +997,96 @@ async function startP2PReceive(opts) {
619
997
  currentSessionId = msg.sessionId;
620
998
  }
621
999
  const name = String(msg.name || "file");
622
- total = Number(msg.size) || 0;
1000
+ const fileSize = Number(msg.size) || 0;
1001
+ const fi = msg.fileIndex;
1002
+ if (fileList && typeof fi === "number" && fi > 0) {
1003
+ currentFileReceived = 0;
1004
+ onFileStart?.({ fileIndex: fi, name, size: fileSize });
1005
+ break;
1006
+ }
623
1007
  received = 0;
1008
+ currentFileReceived = 0;
1009
+ totalReceivedAllFiles = 0;
1010
+ if (!fileList) {
1011
+ total = fileSize;
1012
+ }
624
1013
  writeQueue = Promise.resolve();
625
1014
  const sendReady = () => {
626
- state = "transferring";
1015
+ transitionTo("transferring");
627
1016
  resetWatchdog();
1017
+ if (fileList) {
1018
+ onFileStart?.({ fileIndex: 0, name, size: fileSize });
1019
+ }
628
1020
  try {
629
1021
  conn.send({ t: "ready" });
630
1022
  } catch {
631
1023
  }
632
1024
  };
1025
+ const metaEvt = { name, total };
1026
+ if (fileList) {
1027
+ metaEvt.fileCount = fileList.fileCount;
1028
+ metaEvt.files = fileList.files.map((f) => ({ name: f.name, size: f.size }));
1029
+ metaEvt.totalSize = fileList.totalSize;
1030
+ }
633
1031
  if (autoReady) {
634
- onMeta?.({ name, total });
635
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1032
+ if (!isStopped()) {
1033
+ onMeta?.(metaEvt);
1034
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1035
+ }
636
1036
  sendReady();
637
1037
  } else {
638
- onMeta?.({ name, total, sendReady });
639
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1038
+ metaEvt.sendReady = sendReady;
1039
+ if (!isStopped()) {
1040
+ onMeta?.(metaEvt);
1041
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1042
+ }
640
1043
  }
641
- return;
1044
+ break;
1045
+ }
1046
+ case "chunk": {
1047
+ const chunkMsg = msg;
1048
+ if (state !== "transferring") {
1049
+ throw new DropgateValidationError(
1050
+ "Received chunk message before transfer was accepted."
1051
+ );
1052
+ }
1053
+ if (chunkMsg.seq !== expectedChunkSeq) {
1054
+ throw new DropgateValidationError(
1055
+ `Chunk sequence error: expected ${expectedChunkSeq}, got ${chunkMsg.seq}`
1056
+ );
1057
+ }
1058
+ expectedChunkSeq++;
1059
+ pendingChunk = chunkMsg;
1060
+ break;
642
1061
  }
643
- if (msg.t === "ping") {
1062
+ case "ping":
644
1063
  try {
645
- conn.send({ t: "pong" });
1064
+ conn.send({ t: "pong", timestamp: Date.now() });
646
1065
  } catch {
647
1066
  }
648
- return;
1067
+ break;
1068
+ case "file_end": {
1069
+ clearWatchdog();
1070
+ await writeQueue;
1071
+ const feIdx = msg.fileIndex;
1072
+ onFileEnd?.({ fileIndex: feIdx, receivedBytes: currentFileReceived });
1073
+ try {
1074
+ conn.send({ t: "file_end_ack", fileIndex: feIdx, received: currentFileReceived, size: currentFileReceived });
1075
+ } catch {
1076
+ }
1077
+ totalReceivedAllFiles += currentFileReceived;
1078
+ currentFileReceived = 0;
1079
+ resetWatchdog();
1080
+ break;
649
1081
  }
650
- if (msg.t === "end") {
1082
+ case "end":
651
1083
  clearWatchdog();
652
1084
  await writeQueue;
653
- if (total && received < total) {
1085
+ const finalReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
1086
+ const finalTotal = fileList ? fileList.totalSize : total;
1087
+ if (finalTotal && finalReceived < finalTotal) {
654
1088
  const err = new DropgateNetworkError(
655
- "Transfer ended before the full file was received."
1089
+ "Transfer ended before all data was received."
656
1090
  );
657
1091
  try {
658
1092
  conn.send({ t: "error", message: err.message });
@@ -661,62 +1095,31 @@ async function startP2PReceive(opts) {
661
1095
  throw err;
662
1096
  }
663
1097
  try {
664
- conn.send({ t: "ack", phase: "end", received, total });
1098
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
665
1099
  } catch {
666
1100
  }
667
- safeComplete({ received, total });
668
- return;
669
- }
670
- if (msg.t === "error") {
1101
+ safeComplete({ received: finalReceived, total: finalTotal });
1102
+ (async () => {
1103
+ for (let i = 0; i < 2; i++) {
1104
+ await sleep(P2P_END_ACK_RETRY_DELAY_MS);
1105
+ try {
1106
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
1107
+ } catch {
1108
+ break;
1109
+ }
1110
+ }
1111
+ })().catch(() => {
1112
+ });
1113
+ break;
1114
+ case "error":
671
1115
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
672
- }
673
- if (msg.t === "cancelled") {
1116
+ case "cancelled":
674
1117
  if (state === "cancelled" || state === "closed" || state === "completed") return;
675
- state = "cancelled";
676
- onCancel?.({ cancelledBy: "sender", message: msg.message });
1118
+ transitionTo("cancelled");
1119
+ onCancel?.({ cancelledBy: "sender", message: msg.reason });
677
1120
  cleanup();
678
- return;
679
- }
680
- return;
1121
+ break;
681
1122
  }
682
- let bufPromise;
683
- if (data instanceof ArrayBuffer) {
684
- bufPromise = Promise.resolve(new Uint8Array(data));
685
- } else if (ArrayBuffer.isView(data)) {
686
- bufPromise = Promise.resolve(
687
- new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
688
- );
689
- } else if (typeof Blob !== "undefined" && data instanceof Blob) {
690
- bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
691
- } else {
692
- return;
693
- }
694
- writeQueue = writeQueue.then(async () => {
695
- const buf = await bufPromise;
696
- if (onData) {
697
- await onData(buf);
698
- }
699
- received += buf.byteLength;
700
- const percent = total ? Math.min(100, received / total * 100) : 0;
701
- onProgress?.({ processedBytes: received, totalBytes: total, percent });
702
- const now = Date.now();
703
- if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
704
- lastProgressSentAt = now;
705
- try {
706
- conn.send({ t: "progress", received, total });
707
- } catch {
708
- }
709
- }
710
- }).catch((err) => {
711
- try {
712
- conn.send({
713
- t: "error",
714
- message: err?.message || "Receiver write failed."
715
- });
716
- } catch {
717
- }
718
- safeError(err);
719
- });
720
1123
  } catch (err) {
721
1124
  safeError(err);
722
1125
  }
@@ -727,11 +1130,11 @@ async function startP2PReceive(opts) {
727
1130
  return;
728
1131
  }
729
1132
  if (state === "transferring") {
730
- state = "cancelled";
1133
+ transitionTo("cancelled");
731
1134
  onCancel?.({ cancelledBy: "sender" });
732
1135
  cleanup();
733
1136
  } else if (state === "negotiating") {
734
- state = "closed";
1137
+ transitionTo("closed");
735
1138
  cleanup();
736
1139
  onDisconnect?.();
737
1140
  } else {
@@ -749,11 +1152,18 @@ async function startP2PReceive(opts) {
749
1152
  };
750
1153
  }
751
1154
  export {
1155
+ P2P_CHUNK_SIZE,
1156
+ P2P_END_ACK_RETRIES,
1157
+ P2P_END_ACK_TIMEOUT_MS,
1158
+ P2P_MAX_UNACKED_CHUNKS,
1159
+ P2P_PROTOCOL_VERSION,
752
1160
  buildPeerOptions,
753
1161
  createPeerWithRetries,
754
1162
  generateP2PCode,
755
1163
  isLocalhostHostname,
756
1164
  isP2PCodeLike,
1165
+ isP2PMessage,
1166
+ isProtocolCompatible,
757
1167
  isSecureContextForP2P,
758
1168
  resolvePeerConfig,
759
1169
  startP2PReceive,