@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.cjs CHANGED
@@ -576,7 +576,7 @@ var DropgateClient = class {
576
576
  }
577
577
  if (encrypt && !caps.e2ee) {
578
578
  throw new DropgateValidationError(
579
- "Server does not support end-to-end encryption."
579
+ "End-to-end encryption is not supported on this server."
580
580
  );
581
581
  }
582
582
  return true;
@@ -600,201 +600,253 @@ var DropgateClient = class {
600
600
  encrypt,
601
601
  filenameOverride,
602
602
  onProgress,
603
+ onCancel,
603
604
  signal,
604
605
  timeouts = {},
605
606
  retry = {}
606
607
  } = opts;
607
- const progress = (evt) => {
608
+ const internalController = signal ? null : new AbortController();
609
+ const effectiveSignal = signal || internalController?.signal;
610
+ let uploadState = "initializing";
611
+ let currentUploadId = null;
612
+ let currentBaseUrl = null;
613
+ const uploadPromise = (async () => {
608
614
  try {
609
- if (onProgress) onProgress(evt);
610
- } catch {
611
- }
612
- };
613
- if (!this.cryptoObj?.subtle) {
614
- throw new DropgateValidationError(
615
- "Web Crypto API not available (crypto.subtle)."
616
- );
617
- }
618
- const fileSizeBytes = file.size;
619
- progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
620
- const compat = await this.checkCompatibility({
621
- host,
622
- port,
623
- secure,
624
- timeoutMs: timeouts.serverInfoMs ?? 5e3,
625
- signal
626
- });
627
- const { baseUrl, serverInfo } = compat;
628
- progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
629
- if (!compat.compatible) {
630
- throw new DropgateValidationError(compat.message);
631
- }
632
- const filename = filenameOverride ?? file.name ?? "file";
633
- if (!encrypt) {
634
- validatePlainFilename(filename);
635
- }
636
- this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
637
- let cryptoKey = null;
638
- let keyB64 = null;
639
- let transmittedFilename = filename;
640
- if (encrypt) {
641
- progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
642
- try {
643
- cryptoKey = await generateAesGcmKey(this.cryptoObj);
644
- keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
645
- transmittedFilename = await encryptFilenameToBase64(
646
- this.cryptoObj,
647
- filename,
648
- cryptoKey
615
+ const progress = (evt) => {
616
+ try {
617
+ if (onProgress) onProgress(evt);
618
+ } catch {
619
+ }
620
+ };
621
+ if (!this.cryptoObj?.subtle) {
622
+ throw new DropgateValidationError(
623
+ "Web Crypto API not available (crypto.subtle)."
624
+ );
625
+ }
626
+ const fileSizeBytes = file.size;
627
+ progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
628
+ const compat = await this.checkCompatibility({
629
+ host,
630
+ port,
631
+ secure,
632
+ timeoutMs: timeouts.serverInfoMs ?? 5e3,
633
+ signal: effectiveSignal
634
+ });
635
+ const { baseUrl, serverInfo } = compat;
636
+ progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
637
+ if (!compat.compatible) {
638
+ throw new DropgateValidationError(compat.message);
639
+ }
640
+ const filename = filenameOverride ?? file.name ?? "file";
641
+ const serverSupportsE2EE = Boolean(serverInfo?.capabilities?.upload?.e2ee);
642
+ const effectiveEncrypt = encrypt ?? serverSupportsE2EE;
643
+ if (!effectiveEncrypt) {
644
+ validatePlainFilename(filename);
645
+ }
646
+ this.validateUploadInputs({ file, lifetimeMs, encrypt: effectiveEncrypt, serverInfo });
647
+ let cryptoKey = null;
648
+ let keyB64 = null;
649
+ let transmittedFilename = filename;
650
+ if (effectiveEncrypt) {
651
+ progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
652
+ try {
653
+ cryptoKey = await generateAesGcmKey(this.cryptoObj);
654
+ keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
655
+ transmittedFilename = await encryptFilenameToBase64(
656
+ this.cryptoObj,
657
+ filename,
658
+ cryptoKey
659
+ );
660
+ } catch (err) {
661
+ throw new DropgateError("Failed to prepare encryption.", {
662
+ code: "CRYPTO_PREP_FAILED",
663
+ cause: err
664
+ });
665
+ }
666
+ }
667
+ const totalChunks = Math.ceil(file.size / this.chunkSize);
668
+ const totalUploadSize = estimateTotalUploadSizeBytes(
669
+ file.size,
670
+ totalChunks,
671
+ effectiveEncrypt
649
672
  );
650
- } catch (err) {
651
- throw new DropgateError("Failed to prepare encryption.", {
652
- code: "CRYPTO_PREP_FAILED",
653
- cause: err
673
+ progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
674
+ const initPayload = {
675
+ filename: transmittedFilename,
676
+ lifetime: lifetimeMs,
677
+ isEncrypted: effectiveEncrypt,
678
+ totalSize: totalUploadSize,
679
+ totalChunks
680
+ };
681
+ const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
682
+ method: "POST",
683
+ timeoutMs: timeouts.initMs ?? 15e3,
684
+ signal: effectiveSignal,
685
+ headers: {
686
+ "Content-Type": "application/json",
687
+ Accept: "application/json"
688
+ },
689
+ body: JSON.stringify(initPayload)
654
690
  });
655
- }
656
- }
657
- const totalChunks = Math.ceil(file.size / this.chunkSize);
658
- const totalUploadSize = estimateTotalUploadSizeBytes(
659
- file.size,
660
- totalChunks,
661
- encrypt
662
- );
663
- progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
664
- const initPayload = {
665
- filename: transmittedFilename,
666
- lifetime: lifetimeMs,
667
- isEncrypted: Boolean(encrypt),
668
- totalSize: totalUploadSize,
669
- totalChunks
670
- };
671
- const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
672
- method: "POST",
673
- timeoutMs: timeouts.initMs ?? 15e3,
674
- signal,
675
- headers: {
676
- "Content-Type": "application/json",
677
- Accept: "application/json"
678
- },
679
- body: JSON.stringify(initPayload)
680
- });
681
- if (!initRes.res.ok) {
682
- const errorJson = initRes.json;
683
- const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
684
- throw new DropgateProtocolError(msg, {
685
- details: initRes.json || initRes.text
686
- });
687
- }
688
- const initJson = initRes.json;
689
- const uploadId = initJson?.uploadId;
690
- if (!uploadId || typeof uploadId !== "string") {
691
- throw new DropgateProtocolError(
692
- "Server did not return a valid uploadId."
693
- );
694
- }
695
- const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
696
- const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
697
- const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
698
- for (let i = 0; i < totalChunks; i++) {
699
- if (signal?.aborted) {
700
- throw signal.reason || new DropgateAbortError();
701
- }
702
- const start = i * this.chunkSize;
703
- const end = Math.min(start + this.chunkSize, file.size);
704
- let chunkBlob = file.slice(start, end);
705
- const percentComplete = i / totalChunks * 100;
706
- const processedBytes = i * this.chunkSize;
707
- progress({
708
- phase: "chunk",
709
- text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
710
- percent: percentComplete,
711
- processedBytes,
712
- totalBytes: fileSizeBytes,
713
- chunkIndex: i,
714
- totalChunks
715
- });
716
- const chunkBuffer = await chunkBlob.arrayBuffer();
717
- let uploadBlob;
718
- if (encrypt && cryptoKey) {
719
- uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
720
- } else {
721
- uploadBlob = new Blob([chunkBuffer]);
722
- }
723
- if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
724
- throw new DropgateValidationError(
725
- "Chunk too large (client-side). Check chunk size settings."
691
+ if (!initRes.res.ok) {
692
+ const errorJson = initRes.json;
693
+ const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
694
+ throw new DropgateProtocolError(msg, {
695
+ details: initRes.json || initRes.text
696
+ });
697
+ }
698
+ const initJson = initRes.json;
699
+ const uploadId = initJson?.uploadId;
700
+ if (!uploadId || typeof uploadId !== "string") {
701
+ throw new DropgateProtocolError(
702
+ "Server did not return a valid uploadId."
703
+ );
704
+ }
705
+ currentUploadId = uploadId;
706
+ currentBaseUrl = baseUrl;
707
+ uploadState = "uploading";
708
+ const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
709
+ const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
710
+ const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
711
+ for (let i = 0; i < totalChunks; i++) {
712
+ if (effectiveSignal?.aborted) {
713
+ throw effectiveSignal.reason || new DropgateAbortError();
714
+ }
715
+ const start = i * this.chunkSize;
716
+ const end = Math.min(start + this.chunkSize, file.size);
717
+ let chunkBlob = file.slice(start, end);
718
+ const percentComplete = i / totalChunks * 100;
719
+ const processedBytes = i * this.chunkSize;
720
+ progress({
721
+ phase: "chunk",
722
+ text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
723
+ percent: percentComplete,
724
+ processedBytes,
725
+ totalBytes: fileSizeBytes,
726
+ chunkIndex: i,
727
+ totalChunks
728
+ });
729
+ const chunkBuffer = await chunkBlob.arrayBuffer();
730
+ let uploadBlob;
731
+ if (effectiveEncrypt && cryptoKey) {
732
+ uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
733
+ } else {
734
+ uploadBlob = new Blob([chunkBuffer]);
735
+ }
736
+ if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
737
+ throw new DropgateValidationError(
738
+ "Chunk too large (client-side). Check chunk size settings."
739
+ );
740
+ }
741
+ const toHash = await uploadBlob.arrayBuffer();
742
+ const hashHex = await sha256Hex(this.cryptoObj, toHash);
743
+ const headers = {
744
+ "Content-Type": "application/octet-stream",
745
+ "X-Upload-ID": uploadId,
746
+ "X-Chunk-Index": String(i),
747
+ "X-Chunk-Hash": hashHex
748
+ };
749
+ const chunkUrl = `${baseUrl}/upload/chunk`;
750
+ await this.attemptChunkUpload(
751
+ chunkUrl,
752
+ {
753
+ method: "POST",
754
+ headers,
755
+ body: uploadBlob
756
+ },
757
+ {
758
+ retries,
759
+ backoffMs: baseBackoffMs,
760
+ maxBackoffMs,
761
+ timeoutMs: timeouts.chunkMs ?? 6e4,
762
+ signal: effectiveSignal,
763
+ progress,
764
+ chunkIndex: i,
765
+ totalChunks,
766
+ chunkSize: this.chunkSize,
767
+ fileSizeBytes
768
+ }
769
+ );
770
+ }
771
+ progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
772
+ uploadState = "completing";
773
+ const completeRes = await fetchJson(
774
+ this.fetchFn,
775
+ `${baseUrl}/upload/complete`,
776
+ {
777
+ method: "POST",
778
+ timeoutMs: timeouts.completeMs ?? 3e4,
779
+ signal: effectiveSignal,
780
+ headers: {
781
+ "Content-Type": "application/json",
782
+ Accept: "application/json"
783
+ },
784
+ body: JSON.stringify({ uploadId })
785
+ }
726
786
  );
787
+ if (!completeRes.res.ok) {
788
+ const errorJson = completeRes.json;
789
+ const msg = errorJson?.error || "Finalisation failed.";
790
+ throw new DropgateProtocolError(msg, {
791
+ details: completeRes.json || completeRes.text
792
+ });
793
+ }
794
+ const completeJson = completeRes.json;
795
+ const fileId = completeJson?.id;
796
+ if (!fileId || typeof fileId !== "string") {
797
+ throw new DropgateProtocolError(
798
+ "Server did not return a valid file id."
799
+ );
800
+ }
801
+ let downloadUrl = `${baseUrl}/${fileId}`;
802
+ if (effectiveEncrypt && keyB64) {
803
+ downloadUrl += `#${keyB64}`;
804
+ }
805
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
806
+ uploadState = "completed";
807
+ return {
808
+ downloadUrl,
809
+ fileId,
810
+ uploadId,
811
+ baseUrl,
812
+ ...effectiveEncrypt && keyB64 ? { keyB64 } : {}
813
+ };
814
+ } catch (err) {
815
+ if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
816
+ uploadState = "cancelled";
817
+ onCancel?.();
818
+ } else {
819
+ uploadState = "error";
820
+ }
821
+ throw err;
727
822
  }
728
- const toHash = await uploadBlob.arrayBuffer();
729
- const hashHex = await sha256Hex(this.cryptoObj, toHash);
730
- const headers = {
731
- "Content-Type": "application/octet-stream",
732
- "X-Upload-ID": uploadId,
733
- "X-Chunk-Index": String(i),
734
- "X-Chunk-Hash": hashHex
735
- };
736
- const chunkUrl = `${baseUrl}/upload/chunk`;
737
- await this.attemptChunkUpload(
738
- chunkUrl,
739
- {
823
+ })();
824
+ const callCancelEndpoint = async (uploadId, baseUrl) => {
825
+ try {
826
+ await fetchJson(this.fetchFn, `${baseUrl}/upload/cancel`, {
740
827
  method: "POST",
741
- headers,
742
- body: uploadBlob
743
- },
744
- {
745
- retries,
746
- backoffMs: baseBackoffMs,
747
- maxBackoffMs,
748
- timeoutMs: timeouts.chunkMs ?? 6e4,
749
- signal,
750
- progress,
751
- chunkIndex: i,
752
- totalChunks,
753
- chunkSize: this.chunkSize,
754
- fileSizeBytes
755
- }
756
- );
757
- }
758
- progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
759
- const completeRes = await fetchJson(
760
- this.fetchFn,
761
- `${baseUrl}/upload/complete`,
762
- {
763
- method: "POST",
764
- timeoutMs: timeouts.completeMs ?? 3e4,
765
- signal,
766
- headers: {
767
- "Content-Type": "application/json",
768
- Accept: "application/json"
769
- },
770
- body: JSON.stringify({ uploadId })
828
+ timeoutMs: 5e3,
829
+ headers: {
830
+ "Content-Type": "application/json",
831
+ Accept: "application/json"
832
+ },
833
+ body: JSON.stringify({ uploadId })
834
+ });
835
+ } catch {
771
836
  }
772
- );
773
- if (!completeRes.res.ok) {
774
- const errorJson = completeRes.json;
775
- const msg = errorJson?.error || "Finalisation failed.";
776
- throw new DropgateProtocolError(msg, {
777
- details: completeRes.json || completeRes.text
778
- });
779
- }
780
- const completeJson = completeRes.json;
781
- const fileId = completeJson?.id;
782
- if (!fileId || typeof fileId !== "string") {
783
- throw new DropgateProtocolError(
784
- "Server did not return a valid file id."
785
- );
786
- }
787
- let downloadUrl = `${baseUrl}/${fileId}`;
788
- if (encrypt && keyB64) {
789
- downloadUrl += `#${keyB64}`;
790
- }
791
- progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
837
+ };
792
838
  return {
793
- downloadUrl,
794
- fileId,
795
- uploadId,
796
- baseUrl,
797
- ...encrypt && keyB64 ? { keyB64 } : {}
839
+ result: uploadPromise,
840
+ cancel: (reason) => {
841
+ if (uploadState === "completed" || uploadState === "cancelled") return;
842
+ uploadState = "cancelled";
843
+ if (currentUploadId && currentBaseUrl) {
844
+ callCancelEndpoint(currentUploadId, currentBaseUrl).catch(() => {
845
+ });
846
+ }
847
+ internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
848
+ },
849
+ getStatus: () => uploadState
798
850
  };
799
851
  }
800
852
  /**
@@ -1239,7 +1291,8 @@ async function startP2PSend(opts) {
1239
1291
  onProgress,
1240
1292
  onComplete,
1241
1293
  onError,
1242
- onDisconnect
1294
+ onDisconnect,
1295
+ onCancel
1243
1296
  } = opts;
1244
1297
  if (!file) {
1245
1298
  throw new DropgateValidationError("File is missing.");
@@ -1285,7 +1338,7 @@ async function startP2PSend(opts) {
1285
1338
  onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1286
1339
  };
1287
1340
  const safeError = (err) => {
1288
- if (state === "closed" || state === "completed") return;
1341
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1289
1342
  state = "closed";
1290
1343
  onError?.(err);
1291
1344
  cleanup();
@@ -1324,11 +1377,21 @@ async function startP2PSend(opts) {
1324
1377
  window.addEventListener("beforeunload", handleUnload);
1325
1378
  }
1326
1379
  const stop = () => {
1327
- if (state === "closed") return;
1328
- state = "closed";
1380
+ if (state === "closed" || state === "cancelled") return;
1381
+ const wasActive = state === "transferring" || state === "finishing";
1382
+ state = "cancelled";
1383
+ try {
1384
+ if (activeConn && activeConn.open) {
1385
+ activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
1386
+ }
1387
+ } catch {
1388
+ }
1389
+ if (wasActive && onCancel) {
1390
+ onCancel({ cancelledBy: "sender" });
1391
+ }
1329
1392
  cleanup();
1330
1393
  };
1331
- const isStopped = () => state === "closed";
1394
+ const isStopped = () => state === "closed" || state === "cancelled";
1332
1395
  peer.on("connection", (conn) => {
1333
1396
  if (state === "closed") return;
1334
1397
  if (activeConn) {
@@ -1398,6 +1461,13 @@ async function startP2PSend(opts) {
1398
1461
  }
1399
1462
  if (msg.t === "error") {
1400
1463
  safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1464
+ return;
1465
+ }
1466
+ if (msg.t === "cancelled") {
1467
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1468
+ state = "cancelled";
1469
+ onCancel?.({ cancelledBy: "receiver", message: msg.message });
1470
+ cleanup();
1401
1471
  }
1402
1472
  });
1403
1473
  conn.on("open", async () => {
@@ -1435,6 +1505,7 @@ async function startP2PSend(opts) {
1435
1505
  if (isStopped()) return;
1436
1506
  const slice = file.slice(offset, offset + chunkSize);
1437
1507
  const buf = await slice.arrayBuffer();
1508
+ if (isStopped()) return;
1438
1509
  conn.send(buf);
1439
1510
  sentBytes += buf.byteLength;
1440
1511
  if (dc) {
@@ -1484,14 +1555,14 @@ async function startP2PSend(opts) {
1484
1555
  safeError(err);
1485
1556
  });
1486
1557
  conn.on("close", () => {
1487
- if (state === "closed" || state === "completed") {
1558
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1488
1559
  cleanup();
1489
1560
  return;
1490
1561
  }
1491
1562
  if (state === "transferring" || state === "finishing") {
1492
- safeError(
1493
- new DropgateNetworkError("Receiver disconnected before transfer completed.")
1494
- );
1563
+ state = "cancelled";
1564
+ onCancel?.({ cancelledBy: "receiver" });
1565
+ cleanup();
1495
1566
  } else {
1496
1567
  activeConn = null;
1497
1568
  state = "listening";
@@ -1533,7 +1604,8 @@ async function startP2PReceive(opts) {
1533
1604
  onProgress,
1534
1605
  onComplete,
1535
1606
  onError,
1536
- onDisconnect
1607
+ onDisconnect,
1608
+ onCancel
1537
1609
  } = opts;
1538
1610
  if (!code) {
1539
1611
  throw new DropgateValidationError("No sharing code was provided.");
@@ -1590,7 +1662,7 @@ async function startP2PReceive(opts) {
1590
1662
  }
1591
1663
  };
1592
1664
  const safeError = (err) => {
1593
- if (state === "closed" || state === "completed") return;
1665
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1594
1666
  state = "closed";
1595
1667
  onError?.(err);
1596
1668
  cleanup();
@@ -1622,8 +1694,18 @@ async function startP2PReceive(opts) {
1622
1694
  window.addEventListener("beforeunload", handleUnload);
1623
1695
  }
1624
1696
  const stop = () => {
1625
- if (state === "closed") return;
1626
- state = "closed";
1697
+ if (state === "closed" || state === "cancelled") return;
1698
+ const wasActive = state === "transferring";
1699
+ state = "cancelled";
1700
+ try {
1701
+ if (activeConn && activeConn.open) {
1702
+ activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
1703
+ }
1704
+ } catch {
1705
+ }
1706
+ if (wasActive && onCancel) {
1707
+ onCancel({ cancelledBy: "receiver" });
1708
+ }
1627
1709
  cleanup();
1628
1710
  };
1629
1711
  peer.on("error", (err) => {
@@ -1705,6 +1787,13 @@ async function startP2PReceive(opts) {
1705
1787
  if (msg.t === "error") {
1706
1788
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
1707
1789
  }
1790
+ if (msg.t === "cancelled") {
1791
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1792
+ state = "cancelled";
1793
+ onCancel?.({ cancelledBy: "sender", message: msg.message });
1794
+ cleanup();
1795
+ return;
1796
+ }
1708
1797
  return;
1709
1798
  }
1710
1799
  let bufPromise;
@@ -1750,12 +1839,14 @@ async function startP2PReceive(opts) {
1750
1839
  }
1751
1840
  });
1752
1841
  conn.on("close", () => {
1753
- if (state === "closed" || state === "completed") {
1842
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1754
1843
  cleanup();
1755
1844
  return;
1756
1845
  }
1757
1846
  if (state === "transferring") {
1758
- safeError(new DropgateNetworkError("Sender disconnected during transfer."));
1847
+ state = "cancelled";
1848
+ onCancel?.({ cancelledBy: "sender" });
1849
+ cleanup();
1759
1850
  } else if (state === "negotiating") {
1760
1851
  state = "closed";
1761
1852
  cleanup();