@dropgate/core 2.1.0 → 2.2.0-beta.2

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/index.js CHANGED
@@ -507,7 +507,7 @@ var DropgateClient = class {
507
507
  }
508
508
  if (encrypt && !caps.e2ee) {
509
509
  throw new DropgateValidationError(
510
- "Server does not support end-to-end encryption."
510
+ "End-to-end encryption is not supported on this server."
511
511
  );
512
512
  }
513
513
  return true;
@@ -531,201 +531,253 @@ var DropgateClient = class {
531
531
  encrypt,
532
532
  filenameOverride,
533
533
  onProgress,
534
+ onCancel,
534
535
  signal,
535
536
  timeouts = {},
536
537
  retry = {}
537
538
  } = opts;
538
- const progress = (evt) => {
539
+ const internalController = signal ? null : new AbortController();
540
+ const effectiveSignal = signal || internalController?.signal;
541
+ let uploadState = "initializing";
542
+ let currentUploadId = null;
543
+ let currentBaseUrl = null;
544
+ const uploadPromise = (async () => {
539
545
  try {
540
- if (onProgress) onProgress(evt);
541
- } catch {
542
- }
543
- };
544
- if (!this.cryptoObj?.subtle) {
545
- throw new DropgateValidationError(
546
- "Web Crypto API not available (crypto.subtle)."
547
- );
548
- }
549
- const fileSizeBytes = file.size;
550
- progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
551
- const compat = await this.checkCompatibility({
552
- host,
553
- port,
554
- secure,
555
- timeoutMs: timeouts.serverInfoMs ?? 5e3,
556
- signal
557
- });
558
- const { baseUrl, serverInfo } = compat;
559
- progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
560
- if (!compat.compatible) {
561
- throw new DropgateValidationError(compat.message);
562
- }
563
- const filename = filenameOverride ?? file.name ?? "file";
564
- if (!encrypt) {
565
- validatePlainFilename(filename);
566
- }
567
- this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
568
- let cryptoKey = null;
569
- let keyB64 = null;
570
- let transmittedFilename = filename;
571
- if (encrypt) {
572
- progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
573
- try {
574
- cryptoKey = await generateAesGcmKey(this.cryptoObj);
575
- keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
576
- transmittedFilename = await encryptFilenameToBase64(
577
- this.cryptoObj,
578
- filename,
579
- cryptoKey
546
+ const progress = (evt) => {
547
+ try {
548
+ if (onProgress) onProgress(evt);
549
+ } catch {
550
+ }
551
+ };
552
+ if (!this.cryptoObj?.subtle) {
553
+ throw new DropgateValidationError(
554
+ "Web Crypto API not available (crypto.subtle)."
555
+ );
556
+ }
557
+ const fileSizeBytes = file.size;
558
+ progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
559
+ const compat = await this.checkCompatibility({
560
+ host,
561
+ port,
562
+ secure,
563
+ timeoutMs: timeouts.serverInfoMs ?? 5e3,
564
+ signal: effectiveSignal
565
+ });
566
+ const { baseUrl, serverInfo } = compat;
567
+ progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
568
+ if (!compat.compatible) {
569
+ throw new DropgateValidationError(compat.message);
570
+ }
571
+ const filename = filenameOverride ?? file.name ?? "file";
572
+ const serverSupportsE2EE = Boolean(serverInfo?.capabilities?.upload?.e2ee);
573
+ const effectiveEncrypt = encrypt ?? serverSupportsE2EE;
574
+ if (!effectiveEncrypt) {
575
+ validatePlainFilename(filename);
576
+ }
577
+ this.validateUploadInputs({ file, lifetimeMs, encrypt: effectiveEncrypt, serverInfo });
578
+ let cryptoKey = null;
579
+ let keyB64 = null;
580
+ let transmittedFilename = filename;
581
+ if (effectiveEncrypt) {
582
+ progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
583
+ try {
584
+ cryptoKey = await generateAesGcmKey(this.cryptoObj);
585
+ keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
586
+ transmittedFilename = await encryptFilenameToBase64(
587
+ this.cryptoObj,
588
+ filename,
589
+ cryptoKey
590
+ );
591
+ } catch (err) {
592
+ throw new DropgateError("Failed to prepare encryption.", {
593
+ code: "CRYPTO_PREP_FAILED",
594
+ cause: err
595
+ });
596
+ }
597
+ }
598
+ const totalChunks = Math.ceil(file.size / this.chunkSize);
599
+ const totalUploadSize = estimateTotalUploadSizeBytes(
600
+ file.size,
601
+ totalChunks,
602
+ effectiveEncrypt
580
603
  );
581
- } catch (err) {
582
- throw new DropgateError("Failed to prepare encryption.", {
583
- code: "CRYPTO_PREP_FAILED",
584
- cause: err
604
+ progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
605
+ const initPayload = {
606
+ filename: transmittedFilename,
607
+ lifetime: lifetimeMs,
608
+ isEncrypted: effectiveEncrypt,
609
+ totalSize: totalUploadSize,
610
+ totalChunks
611
+ };
612
+ const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
613
+ method: "POST",
614
+ timeoutMs: timeouts.initMs ?? 15e3,
615
+ signal: effectiveSignal,
616
+ headers: {
617
+ "Content-Type": "application/json",
618
+ Accept: "application/json"
619
+ },
620
+ body: JSON.stringify(initPayload)
585
621
  });
586
- }
587
- }
588
- const totalChunks = Math.ceil(file.size / this.chunkSize);
589
- const totalUploadSize = estimateTotalUploadSizeBytes(
590
- file.size,
591
- totalChunks,
592
- encrypt
593
- );
594
- progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
595
- const initPayload = {
596
- filename: transmittedFilename,
597
- lifetime: lifetimeMs,
598
- isEncrypted: Boolean(encrypt),
599
- totalSize: totalUploadSize,
600
- totalChunks
601
- };
602
- const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
603
- method: "POST",
604
- timeoutMs: timeouts.initMs ?? 15e3,
605
- signal,
606
- headers: {
607
- "Content-Type": "application/json",
608
- Accept: "application/json"
609
- },
610
- body: JSON.stringify(initPayload)
611
- });
612
- if (!initRes.res.ok) {
613
- const errorJson = initRes.json;
614
- const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
615
- throw new DropgateProtocolError(msg, {
616
- details: initRes.json || initRes.text
617
- });
618
- }
619
- const initJson = initRes.json;
620
- const uploadId = initJson?.uploadId;
621
- if (!uploadId || typeof uploadId !== "string") {
622
- throw new DropgateProtocolError(
623
- "Server did not return a valid uploadId."
624
- );
625
- }
626
- const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
627
- const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
628
- const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
629
- for (let i = 0; i < totalChunks; i++) {
630
- if (signal?.aborted) {
631
- throw signal.reason || new DropgateAbortError();
632
- }
633
- const start = i * this.chunkSize;
634
- const end = Math.min(start + this.chunkSize, file.size);
635
- let chunkBlob = file.slice(start, end);
636
- const percentComplete = i / totalChunks * 100;
637
- const processedBytes = i * this.chunkSize;
638
- progress({
639
- phase: "chunk",
640
- text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
641
- percent: percentComplete,
642
- processedBytes,
643
- totalBytes: fileSizeBytes,
644
- chunkIndex: i,
645
- totalChunks
646
- });
647
- const chunkBuffer = await chunkBlob.arrayBuffer();
648
- let uploadBlob;
649
- if (encrypt && cryptoKey) {
650
- uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
651
- } else {
652
- uploadBlob = new Blob([chunkBuffer]);
653
- }
654
- if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
655
- throw new DropgateValidationError(
656
- "Chunk too large (client-side). Check chunk size settings."
622
+ if (!initRes.res.ok) {
623
+ const errorJson = initRes.json;
624
+ const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
625
+ throw new DropgateProtocolError(msg, {
626
+ details: initRes.json || initRes.text
627
+ });
628
+ }
629
+ const initJson = initRes.json;
630
+ const uploadId = initJson?.uploadId;
631
+ if (!uploadId || typeof uploadId !== "string") {
632
+ throw new DropgateProtocolError(
633
+ "Server did not return a valid uploadId."
634
+ );
635
+ }
636
+ currentUploadId = uploadId;
637
+ currentBaseUrl = baseUrl;
638
+ uploadState = "uploading";
639
+ const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
640
+ const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
641
+ const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
642
+ for (let i = 0; i < totalChunks; i++) {
643
+ if (effectiveSignal?.aborted) {
644
+ throw effectiveSignal.reason || new DropgateAbortError();
645
+ }
646
+ const start = i * this.chunkSize;
647
+ const end = Math.min(start + this.chunkSize, file.size);
648
+ let chunkBlob = file.slice(start, end);
649
+ const percentComplete = i / totalChunks * 100;
650
+ const processedBytes = i * this.chunkSize;
651
+ progress({
652
+ phase: "chunk",
653
+ text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
654
+ percent: percentComplete,
655
+ processedBytes,
656
+ totalBytes: fileSizeBytes,
657
+ chunkIndex: i,
658
+ totalChunks
659
+ });
660
+ const chunkBuffer = await chunkBlob.arrayBuffer();
661
+ let uploadBlob;
662
+ if (effectiveEncrypt && cryptoKey) {
663
+ uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
664
+ } else {
665
+ uploadBlob = new Blob([chunkBuffer]);
666
+ }
667
+ if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
668
+ throw new DropgateValidationError(
669
+ "Chunk too large (client-side). Check chunk size settings."
670
+ );
671
+ }
672
+ const toHash = await uploadBlob.arrayBuffer();
673
+ const hashHex = await sha256Hex(this.cryptoObj, toHash);
674
+ const headers = {
675
+ "Content-Type": "application/octet-stream",
676
+ "X-Upload-ID": uploadId,
677
+ "X-Chunk-Index": String(i),
678
+ "X-Chunk-Hash": hashHex
679
+ };
680
+ const chunkUrl = `${baseUrl}/upload/chunk`;
681
+ await this.attemptChunkUpload(
682
+ chunkUrl,
683
+ {
684
+ method: "POST",
685
+ headers,
686
+ body: uploadBlob
687
+ },
688
+ {
689
+ retries,
690
+ backoffMs: baseBackoffMs,
691
+ maxBackoffMs,
692
+ timeoutMs: timeouts.chunkMs ?? 6e4,
693
+ signal: effectiveSignal,
694
+ progress,
695
+ chunkIndex: i,
696
+ totalChunks,
697
+ chunkSize: this.chunkSize,
698
+ fileSizeBytes
699
+ }
700
+ );
701
+ }
702
+ progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
703
+ uploadState = "completing";
704
+ const completeRes = await fetchJson(
705
+ this.fetchFn,
706
+ `${baseUrl}/upload/complete`,
707
+ {
708
+ method: "POST",
709
+ timeoutMs: timeouts.completeMs ?? 3e4,
710
+ signal: effectiveSignal,
711
+ headers: {
712
+ "Content-Type": "application/json",
713
+ Accept: "application/json"
714
+ },
715
+ body: JSON.stringify({ uploadId })
716
+ }
657
717
  );
718
+ if (!completeRes.res.ok) {
719
+ const errorJson = completeRes.json;
720
+ const msg = errorJson?.error || "Finalisation failed.";
721
+ throw new DropgateProtocolError(msg, {
722
+ details: completeRes.json || completeRes.text
723
+ });
724
+ }
725
+ const completeJson = completeRes.json;
726
+ const fileId = completeJson?.id;
727
+ if (!fileId || typeof fileId !== "string") {
728
+ throw new DropgateProtocolError(
729
+ "Server did not return a valid file id."
730
+ );
731
+ }
732
+ let downloadUrl = `${baseUrl}/${fileId}`;
733
+ if (effectiveEncrypt && keyB64) {
734
+ downloadUrl += `#${keyB64}`;
735
+ }
736
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
737
+ uploadState = "completed";
738
+ return {
739
+ downloadUrl,
740
+ fileId,
741
+ uploadId,
742
+ baseUrl,
743
+ ...effectiveEncrypt && keyB64 ? { keyB64 } : {}
744
+ };
745
+ } catch (err) {
746
+ if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
747
+ uploadState = "cancelled";
748
+ onCancel?.();
749
+ } else {
750
+ uploadState = "error";
751
+ }
752
+ throw err;
658
753
  }
659
- const toHash = await uploadBlob.arrayBuffer();
660
- const hashHex = await sha256Hex(this.cryptoObj, toHash);
661
- const headers = {
662
- "Content-Type": "application/octet-stream",
663
- "X-Upload-ID": uploadId,
664
- "X-Chunk-Index": String(i),
665
- "X-Chunk-Hash": hashHex
666
- };
667
- const chunkUrl = `${baseUrl}/upload/chunk`;
668
- await this.attemptChunkUpload(
669
- chunkUrl,
670
- {
754
+ })();
755
+ const callCancelEndpoint = async (uploadId, baseUrl) => {
756
+ try {
757
+ await fetchJson(this.fetchFn, `${baseUrl}/upload/cancel`, {
671
758
  method: "POST",
672
- headers,
673
- body: uploadBlob
674
- },
675
- {
676
- retries,
677
- backoffMs: baseBackoffMs,
678
- maxBackoffMs,
679
- timeoutMs: timeouts.chunkMs ?? 6e4,
680
- signal,
681
- progress,
682
- chunkIndex: i,
683
- totalChunks,
684
- chunkSize: this.chunkSize,
685
- fileSizeBytes
686
- }
687
- );
688
- }
689
- progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
690
- const completeRes = await fetchJson(
691
- this.fetchFn,
692
- `${baseUrl}/upload/complete`,
693
- {
694
- method: "POST",
695
- timeoutMs: timeouts.completeMs ?? 3e4,
696
- signal,
697
- headers: {
698
- "Content-Type": "application/json",
699
- Accept: "application/json"
700
- },
701
- body: JSON.stringify({ uploadId })
759
+ timeoutMs: 5e3,
760
+ headers: {
761
+ "Content-Type": "application/json",
762
+ Accept: "application/json"
763
+ },
764
+ body: JSON.stringify({ uploadId })
765
+ });
766
+ } catch {
702
767
  }
703
- );
704
- if (!completeRes.res.ok) {
705
- const errorJson = completeRes.json;
706
- const msg = errorJson?.error || "Finalisation failed.";
707
- throw new DropgateProtocolError(msg, {
708
- details: completeRes.json || completeRes.text
709
- });
710
- }
711
- const completeJson = completeRes.json;
712
- const fileId = completeJson?.id;
713
- if (!fileId || typeof fileId !== "string") {
714
- throw new DropgateProtocolError(
715
- "Server did not return a valid file id."
716
- );
717
- }
718
- let downloadUrl = `${baseUrl}/${fileId}`;
719
- if (encrypt && keyB64) {
720
- downloadUrl += `#${keyB64}`;
721
- }
722
- progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
768
+ };
723
769
  return {
724
- downloadUrl,
725
- fileId,
726
- uploadId,
727
- baseUrl,
728
- ...encrypt && keyB64 ? { keyB64 } : {}
770
+ result: uploadPromise,
771
+ cancel: (reason) => {
772
+ if (uploadState === "completed" || uploadState === "cancelled") return;
773
+ uploadState = "cancelled";
774
+ if (currentUploadId && currentBaseUrl) {
775
+ callCancelEndpoint(currentUploadId, currentBaseUrl).catch(() => {
776
+ });
777
+ }
778
+ internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
779
+ },
780
+ getStatus: () => uploadState
729
781
  };
730
782
  }
731
783
  /**
@@ -1170,7 +1222,8 @@ async function startP2PSend(opts) {
1170
1222
  onProgress,
1171
1223
  onComplete,
1172
1224
  onError,
1173
- onDisconnect
1225
+ onDisconnect,
1226
+ onCancel
1174
1227
  } = opts;
1175
1228
  if (!file) {
1176
1229
  throw new DropgateValidationError("File is missing.");
@@ -1216,7 +1269,7 @@ async function startP2PSend(opts) {
1216
1269
  onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1217
1270
  };
1218
1271
  const safeError = (err) => {
1219
- if (state === "closed" || state === "completed") return;
1272
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1220
1273
  state = "closed";
1221
1274
  onError?.(err);
1222
1275
  cleanup();
@@ -1255,11 +1308,21 @@ async function startP2PSend(opts) {
1255
1308
  window.addEventListener("beforeunload", handleUnload);
1256
1309
  }
1257
1310
  const stop = () => {
1258
- if (state === "closed") return;
1259
- state = "closed";
1311
+ if (state === "closed" || state === "cancelled") return;
1312
+ const wasActive = state === "transferring" || state === "finishing";
1313
+ state = "cancelled";
1314
+ try {
1315
+ if (activeConn && activeConn.open) {
1316
+ activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
1317
+ }
1318
+ } catch {
1319
+ }
1320
+ if (wasActive && onCancel) {
1321
+ onCancel({ cancelledBy: "sender" });
1322
+ }
1260
1323
  cleanup();
1261
1324
  };
1262
- const isStopped = () => state === "closed";
1325
+ const isStopped = () => state === "closed" || state === "cancelled";
1263
1326
  peer.on("connection", (conn) => {
1264
1327
  if (state === "closed") return;
1265
1328
  if (activeConn) {
@@ -1329,6 +1392,13 @@ async function startP2PSend(opts) {
1329
1392
  }
1330
1393
  if (msg.t === "error") {
1331
1394
  safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1395
+ return;
1396
+ }
1397
+ if (msg.t === "cancelled") {
1398
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1399
+ state = "cancelled";
1400
+ onCancel?.({ cancelledBy: "receiver", message: msg.message });
1401
+ cleanup();
1332
1402
  }
1333
1403
  });
1334
1404
  conn.on("open", async () => {
@@ -1366,6 +1436,7 @@ async function startP2PSend(opts) {
1366
1436
  if (isStopped()) return;
1367
1437
  const slice = file.slice(offset, offset + chunkSize);
1368
1438
  const buf = await slice.arrayBuffer();
1439
+ if (isStopped()) return;
1369
1440
  conn.send(buf);
1370
1441
  sentBytes += buf.byteLength;
1371
1442
  if (dc) {
@@ -1415,14 +1486,14 @@ async function startP2PSend(opts) {
1415
1486
  safeError(err);
1416
1487
  });
1417
1488
  conn.on("close", () => {
1418
- if (state === "closed" || state === "completed") {
1489
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1419
1490
  cleanup();
1420
1491
  return;
1421
1492
  }
1422
1493
  if (state === "transferring" || state === "finishing") {
1423
- safeError(
1424
- new DropgateNetworkError("Receiver disconnected before transfer completed.")
1425
- );
1494
+ state = "cancelled";
1495
+ onCancel?.({ cancelledBy: "receiver" });
1496
+ cleanup();
1426
1497
  } else {
1427
1498
  activeConn = null;
1428
1499
  state = "listening";
@@ -1464,7 +1535,8 @@ async function startP2PReceive(opts) {
1464
1535
  onProgress,
1465
1536
  onComplete,
1466
1537
  onError,
1467
- onDisconnect
1538
+ onDisconnect,
1539
+ onCancel
1468
1540
  } = opts;
1469
1541
  if (!code) {
1470
1542
  throw new DropgateValidationError("No sharing code was provided.");
@@ -1521,7 +1593,7 @@ async function startP2PReceive(opts) {
1521
1593
  }
1522
1594
  };
1523
1595
  const safeError = (err) => {
1524
- if (state === "closed" || state === "completed") return;
1596
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1525
1597
  state = "closed";
1526
1598
  onError?.(err);
1527
1599
  cleanup();
@@ -1553,8 +1625,18 @@ async function startP2PReceive(opts) {
1553
1625
  window.addEventListener("beforeunload", handleUnload);
1554
1626
  }
1555
1627
  const stop = () => {
1556
- if (state === "closed") return;
1557
- state = "closed";
1628
+ if (state === "closed" || state === "cancelled") return;
1629
+ const wasActive = state === "transferring";
1630
+ state = "cancelled";
1631
+ try {
1632
+ if (activeConn && activeConn.open) {
1633
+ activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
1634
+ }
1635
+ } catch {
1636
+ }
1637
+ if (wasActive && onCancel) {
1638
+ onCancel({ cancelledBy: "receiver" });
1639
+ }
1558
1640
  cleanup();
1559
1641
  };
1560
1642
  peer.on("error", (err) => {
@@ -1636,6 +1718,13 @@ async function startP2PReceive(opts) {
1636
1718
  if (msg.t === "error") {
1637
1719
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
1638
1720
  }
1721
+ if (msg.t === "cancelled") {
1722
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1723
+ state = "cancelled";
1724
+ onCancel?.({ cancelledBy: "sender", message: msg.message });
1725
+ cleanup();
1726
+ return;
1727
+ }
1639
1728
  return;
1640
1729
  }
1641
1730
  let bufPromise;
@@ -1681,12 +1770,14 @@ async function startP2PReceive(opts) {
1681
1770
  }
1682
1771
  });
1683
1772
  conn.on("close", () => {
1684
- if (state === "closed" || state === "completed") {
1773
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1685
1774
  cleanup();
1686
1775
  return;
1687
1776
  }
1688
1777
  if (state === "transferring") {
1689
- safeError(new DropgateNetworkError("Sender disconnected during transfer."));
1778
+ state = "cancelled";
1779
+ onCancel?.({ cancelledBy: "sender" });
1780
+ cleanup();
1690
1781
  } else if (state === "negotiating") {
1691
1782
  state = "closed";
1692
1783
  cleanup();