@dropgate/core 2.0.0-beta.2 → 2.2.0-beta.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/index.cjs CHANGED
@@ -49,6 +49,7 @@ __export(index_exports, {
49
49
  getDefaultBase64: () => getDefaultBase64,
50
50
  getDefaultCrypto: () => getDefaultCrypto,
51
51
  getDefaultFetch: () => getDefaultFetch,
52
+ getServerInfo: () => getServerInfo,
52
53
  importKeyFromBase64: () => importKeyFromBase64,
53
54
  isLocalhostHostname: () => isLocalhostHostname,
54
55
  isP2PCodeLike: () => isP2PCodeLike,
@@ -57,6 +58,7 @@ __export(index_exports, {
57
58
  makeAbortSignal: () => makeAbortSignal,
58
59
  parseSemverMajorMinor: () => parseSemverMajorMinor,
59
60
  parseServerUrl: () => parseServerUrl,
61
+ resolvePeerConfig: () => resolvePeerConfig,
60
62
  sha256Hex: () => sha256Hex,
61
63
  sleep: () => sleep,
62
64
  startP2PReceive: () => startP2PReceive,
@@ -376,6 +378,37 @@ function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
376
378
  if (!isEncrypted) return base;
377
379
  return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
378
380
  }
381
+ async function getServerInfo(opts) {
382
+ const { host, port, secure, timeoutMs = 5e3, signal, fetchFn: customFetch } = opts;
383
+ const fetchFn = customFetch || getDefaultFetch();
384
+ if (!fetchFn) {
385
+ throw new DropgateValidationError("No fetch() implementation found.");
386
+ }
387
+ const baseUrl = buildBaseUrl({ host, port, secure });
388
+ try {
389
+ const { res, json } = await fetchJson(
390
+ fetchFn,
391
+ `${baseUrl}/api/info`,
392
+ {
393
+ method: "GET",
394
+ timeoutMs,
395
+ signal,
396
+ headers: { Accept: "application/json" }
397
+ }
398
+ );
399
+ if (res.ok && json && typeof json === "object" && "version" in json) {
400
+ return { baseUrl, serverInfo: json };
401
+ }
402
+ throw new DropgateProtocolError(
403
+ `Server info request failed (status ${res.status}).`
404
+ );
405
+ } catch (err) {
406
+ if (err instanceof DropgateError) throw err;
407
+ throw new DropgateNetworkError("Could not reach server /api/info.", {
408
+ cause: err
409
+ });
410
+ }
411
+ }
379
412
  var DropgateClient = class {
380
413
  /**
381
414
  * Create a new DropgateClient instance.
@@ -403,40 +436,6 @@ var DropgateClient = class {
403
436
  this.base64 = opts.base64 || getDefaultBase64();
404
437
  this.logger = opts.logger || null;
405
438
  }
406
- /**
407
- * Fetch server information from the /api/info endpoint.
408
- * @param opts - Server target and request options.
409
- * @returns The server base URL and server info object.
410
- * @throws {DropgateNetworkError} If the server cannot be reached.
411
- * @throws {DropgateProtocolError} If the server returns an invalid response.
412
- */
413
- async getServerInfo(opts) {
414
- const { host, port, secure, timeoutMs = 5e3, signal } = opts;
415
- const baseUrl = buildBaseUrl({ host, port, secure });
416
- try {
417
- const { res, json } = await fetchJson(
418
- this.fetchFn,
419
- `${baseUrl}/api/info`,
420
- {
421
- method: "GET",
422
- timeoutMs,
423
- signal,
424
- headers: { Accept: "application/json" }
425
- }
426
- );
427
- if (res.ok && json && typeof json === "object" && "version" in json) {
428
- return { baseUrl, serverInfo: json };
429
- }
430
- throw new DropgateProtocolError(
431
- `Server info request failed (status ${res.status}).`
432
- );
433
- } catch (err) {
434
- if (err instanceof DropgateError) throw err;
435
- throw new DropgateNetworkError("Could not reach server /api/info.", {
436
- cause: err
437
- });
438
- }
439
- }
440
439
  /**
441
440
  * Resolve a user-entered sharing code or URL via the server.
442
441
  * @param value - The sharing code or URL to resolve.
@@ -445,8 +444,12 @@ var DropgateClient = class {
445
444
  * @throws {DropgateProtocolError} If the share lookup fails.
446
445
  */
447
446
  async resolveShareTarget(value, opts) {
448
- const { host, port, secure, timeoutMs = 5e3, signal } = opts;
449
- const baseUrl = buildBaseUrl({ host, port, secure });
447
+ const { timeoutMs = 5e3, signal } = opts;
448
+ const compat = await this.checkCompatibility(opts);
449
+ if (!compat.compatible) {
450
+ throw new DropgateValidationError(compat.message);
451
+ }
452
+ const { baseUrl } = compat;
450
453
  const { res, json } = await fetchJson(
451
454
  this.fetchFn,
452
455
  `${baseUrl}/api/resolve`,
@@ -469,10 +472,25 @@ var DropgateClient = class {
469
472
  }
470
473
  /**
471
474
  * Check version compatibility between this client and a server.
472
- * @param serverInfo - Server info containing the version to check against.
473
- * @returns Compatibility result with status and message.
475
+ * Fetches server info internally using getServerInfo.
476
+ * @param opts - Server target and request options.
477
+ * @returns Compatibility result with status, message, and server info.
478
+ * @throws {DropgateNetworkError} If the server cannot be reached.
479
+ * @throws {DropgateProtocolError} If the server returns an invalid response.
474
480
  */
475
- checkCompatibility(serverInfo) {
481
+ async checkCompatibility(opts) {
482
+ let baseUrl;
483
+ let serverInfo;
484
+ try {
485
+ const result = await getServerInfo({ ...opts, fetchFn: this.fetchFn });
486
+ baseUrl = result.baseUrl;
487
+ serverInfo = result.serverInfo;
488
+ } catch (err) {
489
+ if (err instanceof DropgateError) throw err;
490
+ throw new DropgateNetworkError("Could not connect to the server.", {
491
+ cause: err
492
+ });
493
+ }
476
494
  const serverVersion = String(serverInfo?.version || "0.0.0");
477
495
  const clientVersion = String(this.clientVersion || "0.0.0");
478
496
  const c = parseSemverMajorMinor(clientVersion);
@@ -482,7 +500,9 @@ var DropgateClient = class {
482
500
  compatible: false,
483
501
  clientVersion,
484
502
  serverVersion,
485
- message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
503
+ message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
504
+ serverInfo,
505
+ baseUrl
486
506
  };
487
507
  }
488
508
  if (c.minor > s.minor) {
@@ -490,14 +510,18 @@ var DropgateClient = class {
490
510
  compatible: true,
491
511
  clientVersion,
492
512
  serverVersion,
493
- message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`
513
+ message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`,
514
+ serverInfo,
515
+ baseUrl
494
516
  };
495
517
  }
496
518
  return {
497
519
  compatible: true,
498
520
  clientVersion,
499
521
  serverVersion,
500
- message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
522
+ message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
523
+ serverInfo,
524
+ baseUrl
501
525
  };
502
526
  }
503
527
  /**
@@ -576,206 +600,251 @@ var DropgateClient = class {
576
600
  encrypt,
577
601
  filenameOverride,
578
602
  onProgress,
603
+ onCancel,
579
604
  signal,
580
605
  timeouts = {},
581
606
  retry = {}
582
607
  } = opts;
583
- const progress = (evt) => {
584
- try {
585
- if (onProgress) onProgress(evt);
586
- } catch {
587
- }
588
- };
589
- if (!this.cryptoObj?.subtle) {
590
- throw new DropgateValidationError(
591
- "Web Crypto API not available (crypto.subtle)."
592
- );
593
- }
594
- progress({ phase: "server-info", text: "Checking server..." });
595
- let baseUrl;
596
- let serverInfo;
597
- try {
598
- const res = await this.getServerInfo({
599
- host,
600
- port,
601
- secure,
602
- timeoutMs: timeouts.serverInfoMs ?? 5e3,
603
- signal
604
- });
605
- baseUrl = res.baseUrl;
606
- serverInfo = res.serverInfo;
607
- } catch (err) {
608
- if (err instanceof DropgateError) throw err;
609
- throw new DropgateNetworkError("Could not connect to the server.", {
610
- cause: err
611
- });
612
- }
613
- const compat = this.checkCompatibility(serverInfo);
614
- progress({ phase: "server-compat", text: compat.message });
615
- if (!compat.compatible) {
616
- throw new DropgateValidationError(compat.message);
617
- }
618
- const filename = filenameOverride ?? file.name ?? "file";
619
- if (!encrypt) {
620
- validatePlainFilename(filename);
621
- }
622
- this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
623
- let cryptoKey = null;
624
- let keyB64 = null;
625
- let transmittedFilename = filename;
626
- if (encrypt) {
627
- progress({ phase: "crypto", text: "Generating encryption key..." });
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 () => {
628
614
  try {
629
- cryptoKey = await generateAesGcmKey(this.cryptoObj);
630
- keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
631
- transmittedFilename = await encryptFilenameToBase64(
632
- this.cryptoObj,
633
- filename,
634
- 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
+ if (!encrypt) {
642
+ validatePlainFilename(filename);
643
+ }
644
+ this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
645
+ let cryptoKey = null;
646
+ let keyB64 = null;
647
+ let transmittedFilename = filename;
648
+ if (encrypt) {
649
+ progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
650
+ try {
651
+ cryptoKey = await generateAesGcmKey(this.cryptoObj);
652
+ keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
653
+ transmittedFilename = await encryptFilenameToBase64(
654
+ this.cryptoObj,
655
+ filename,
656
+ cryptoKey
657
+ );
658
+ } catch (err) {
659
+ throw new DropgateError("Failed to prepare encryption.", {
660
+ code: "CRYPTO_PREP_FAILED",
661
+ cause: err
662
+ });
663
+ }
664
+ }
665
+ const totalChunks = Math.ceil(file.size / this.chunkSize);
666
+ const totalUploadSize = estimateTotalUploadSizeBytes(
667
+ file.size,
668
+ totalChunks,
669
+ encrypt
635
670
  );
636
- } catch (err) {
637
- throw new DropgateError("Failed to prepare encryption.", {
638
- code: "CRYPTO_PREP_FAILED",
639
- cause: err
671
+ progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
672
+ const initPayload = {
673
+ filename: transmittedFilename,
674
+ lifetime: lifetimeMs,
675
+ isEncrypted: Boolean(encrypt),
676
+ totalSize: totalUploadSize,
677
+ totalChunks
678
+ };
679
+ const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
680
+ method: "POST",
681
+ timeoutMs: timeouts.initMs ?? 15e3,
682
+ signal: effectiveSignal,
683
+ headers: {
684
+ "Content-Type": "application/json",
685
+ Accept: "application/json"
686
+ },
687
+ body: JSON.stringify(initPayload)
640
688
  });
641
- }
642
- }
643
- const totalChunks = Math.ceil(file.size / this.chunkSize);
644
- const totalUploadSize = estimateTotalUploadSizeBytes(
645
- file.size,
646
- totalChunks,
647
- encrypt
648
- );
649
- progress({ phase: "init", text: "Reserving server storage..." });
650
- const initPayload = {
651
- filename: transmittedFilename,
652
- lifetime: lifetimeMs,
653
- isEncrypted: Boolean(encrypt),
654
- totalSize: totalUploadSize,
655
- totalChunks
656
- };
657
- const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
658
- method: "POST",
659
- timeoutMs: timeouts.initMs ?? 15e3,
660
- signal,
661
- headers: {
662
- "Content-Type": "application/json",
663
- Accept: "application/json"
664
- },
665
- body: JSON.stringify(initPayload)
666
- });
667
- if (!initRes.res.ok) {
668
- const errorJson = initRes.json;
669
- const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
670
- throw new DropgateProtocolError(msg, {
671
- details: initRes.json || initRes.text
672
- });
673
- }
674
- const initJson = initRes.json;
675
- const uploadId = initJson?.uploadId;
676
- if (!uploadId || typeof uploadId !== "string") {
677
- throw new DropgateProtocolError(
678
- "Server did not return a valid uploadId."
679
- );
680
- }
681
- const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
682
- const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
683
- const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
684
- for (let i = 0; i < totalChunks; i++) {
685
- if (signal?.aborted) {
686
- throw signal.reason || new DropgateAbortError();
687
- }
688
- const start = i * this.chunkSize;
689
- const end = Math.min(start + this.chunkSize, file.size);
690
- let chunkBlob = file.slice(start, end);
691
- const percentComplete = i / totalChunks * 100;
692
- progress({
693
- phase: "chunk",
694
- text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
695
- percent: percentComplete,
696
- chunkIndex: i,
697
- totalChunks
698
- });
699
- const chunkBuffer = await chunkBlob.arrayBuffer();
700
- let uploadBlob;
701
- if (encrypt && cryptoKey) {
702
- uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
703
- } else {
704
- uploadBlob = new Blob([chunkBuffer]);
705
- }
706
- if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
707
- throw new DropgateValidationError(
708
- "Chunk too large (client-side). Check chunk size settings."
689
+ if (!initRes.res.ok) {
690
+ const errorJson = initRes.json;
691
+ const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
692
+ throw new DropgateProtocolError(msg, {
693
+ details: initRes.json || initRes.text
694
+ });
695
+ }
696
+ const initJson = initRes.json;
697
+ const uploadId = initJson?.uploadId;
698
+ if (!uploadId || typeof uploadId !== "string") {
699
+ throw new DropgateProtocolError(
700
+ "Server did not return a valid uploadId."
701
+ );
702
+ }
703
+ currentUploadId = uploadId;
704
+ currentBaseUrl = baseUrl;
705
+ uploadState = "uploading";
706
+ const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
707
+ const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
708
+ const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
709
+ for (let i = 0; i < totalChunks; i++) {
710
+ if (effectiveSignal?.aborted) {
711
+ throw effectiveSignal.reason || new DropgateAbortError();
712
+ }
713
+ const start = i * this.chunkSize;
714
+ const end = Math.min(start + this.chunkSize, file.size);
715
+ let chunkBlob = file.slice(start, end);
716
+ const percentComplete = i / totalChunks * 100;
717
+ const processedBytes = i * this.chunkSize;
718
+ progress({
719
+ phase: "chunk",
720
+ text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
721
+ percent: percentComplete,
722
+ processedBytes,
723
+ totalBytes: fileSizeBytes,
724
+ chunkIndex: i,
725
+ totalChunks
726
+ });
727
+ const chunkBuffer = await chunkBlob.arrayBuffer();
728
+ let uploadBlob;
729
+ if (encrypt && cryptoKey) {
730
+ uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
731
+ } else {
732
+ uploadBlob = new Blob([chunkBuffer]);
733
+ }
734
+ if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
735
+ throw new DropgateValidationError(
736
+ "Chunk too large (client-side). Check chunk size settings."
737
+ );
738
+ }
739
+ const toHash = await uploadBlob.arrayBuffer();
740
+ const hashHex = await sha256Hex(this.cryptoObj, toHash);
741
+ const headers = {
742
+ "Content-Type": "application/octet-stream",
743
+ "X-Upload-ID": uploadId,
744
+ "X-Chunk-Index": String(i),
745
+ "X-Chunk-Hash": hashHex
746
+ };
747
+ const chunkUrl = `${baseUrl}/upload/chunk`;
748
+ await this.attemptChunkUpload(
749
+ chunkUrl,
750
+ {
751
+ method: "POST",
752
+ headers,
753
+ body: uploadBlob
754
+ },
755
+ {
756
+ retries,
757
+ backoffMs: baseBackoffMs,
758
+ maxBackoffMs,
759
+ timeoutMs: timeouts.chunkMs ?? 6e4,
760
+ signal: effectiveSignal,
761
+ progress,
762
+ chunkIndex: i,
763
+ totalChunks,
764
+ chunkSize: this.chunkSize,
765
+ fileSizeBytes
766
+ }
767
+ );
768
+ }
769
+ progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
770
+ uploadState = "completing";
771
+ const completeRes = await fetchJson(
772
+ this.fetchFn,
773
+ `${baseUrl}/upload/complete`,
774
+ {
775
+ method: "POST",
776
+ timeoutMs: timeouts.completeMs ?? 3e4,
777
+ signal: effectiveSignal,
778
+ headers: {
779
+ "Content-Type": "application/json",
780
+ Accept: "application/json"
781
+ },
782
+ body: JSON.stringify({ uploadId })
783
+ }
709
784
  );
785
+ if (!completeRes.res.ok) {
786
+ const errorJson = completeRes.json;
787
+ const msg = errorJson?.error || "Finalisation failed.";
788
+ throw new DropgateProtocolError(msg, {
789
+ details: completeRes.json || completeRes.text
790
+ });
791
+ }
792
+ const completeJson = completeRes.json;
793
+ const fileId = completeJson?.id;
794
+ if (!fileId || typeof fileId !== "string") {
795
+ throw new DropgateProtocolError(
796
+ "Server did not return a valid file id."
797
+ );
798
+ }
799
+ let downloadUrl = `${baseUrl}/${fileId}`;
800
+ if (encrypt && keyB64) {
801
+ downloadUrl += `#${keyB64}`;
802
+ }
803
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
804
+ uploadState = "completed";
805
+ return {
806
+ downloadUrl,
807
+ fileId,
808
+ uploadId,
809
+ baseUrl,
810
+ ...encrypt && keyB64 ? { keyB64 } : {}
811
+ };
812
+ } catch (err) {
813
+ if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
814
+ uploadState = "cancelled";
815
+ onCancel?.();
816
+ } else {
817
+ uploadState = "error";
818
+ }
819
+ throw err;
710
820
  }
711
- const toHash = await uploadBlob.arrayBuffer();
712
- const hashHex = await sha256Hex(this.cryptoObj, toHash);
713
- const headers = {
714
- "Content-Type": "application/octet-stream",
715
- "X-Upload-ID": uploadId,
716
- "X-Chunk-Index": String(i),
717
- "X-Chunk-Hash": hashHex
718
- };
719
- const chunkUrl = `${baseUrl}/upload/chunk`;
720
- await this.attemptChunkUpload(
721
- chunkUrl,
722
- {
821
+ })();
822
+ const callCancelEndpoint = async (uploadId, baseUrl) => {
823
+ try {
824
+ await fetchJson(this.fetchFn, `${baseUrl}/upload/cancel`, {
723
825
  method: "POST",
724
- headers,
725
- body: uploadBlob
726
- },
727
- {
728
- retries,
729
- backoffMs: baseBackoffMs,
730
- maxBackoffMs,
731
- timeoutMs: timeouts.chunkMs ?? 6e4,
732
- signal,
733
- progress,
734
- chunkIndex: i,
735
- totalChunks
736
- }
737
- );
738
- }
739
- progress({ phase: "complete", text: "Finalising upload...", percent: 100 });
740
- const completeRes = await fetchJson(
741
- this.fetchFn,
742
- `${baseUrl}/upload/complete`,
743
- {
744
- method: "POST",
745
- timeoutMs: timeouts.completeMs ?? 3e4,
746
- signal,
747
- headers: {
748
- "Content-Type": "application/json",
749
- Accept: "application/json"
750
- },
751
- body: JSON.stringify({ uploadId })
826
+ timeoutMs: 5e3,
827
+ headers: {
828
+ "Content-Type": "application/json",
829
+ Accept: "application/json"
830
+ },
831
+ body: JSON.stringify({ uploadId })
832
+ });
833
+ } catch {
752
834
  }
753
- );
754
- if (!completeRes.res.ok) {
755
- const errorJson = completeRes.json;
756
- const msg = errorJson?.error || "Finalisation failed.";
757
- throw new DropgateProtocolError(msg, {
758
- details: completeRes.json || completeRes.text
759
- });
760
- }
761
- const completeJson = completeRes.json;
762
- const fileId = completeJson?.id;
763
- if (!fileId || typeof fileId !== "string") {
764
- throw new DropgateProtocolError(
765
- "Server did not return a valid file id."
766
- );
767
- }
768
- let downloadUrl = `${baseUrl}/${fileId}`;
769
- if (encrypt && keyB64) {
770
- downloadUrl += `#${keyB64}`;
771
- }
772
- progress({ phase: "done", text: "Upload successful!", percent: 100 });
835
+ };
773
836
  return {
774
- downloadUrl,
775
- fileId,
776
- uploadId,
777
- baseUrl,
778
- ...encrypt && keyB64 ? { keyB64 } : {}
837
+ result: uploadPromise,
838
+ cancel: (reason) => {
839
+ if (uploadState === "completed" || uploadState === "cancelled") return;
840
+ uploadState = "cancelled";
841
+ if (currentUploadId && currentBaseUrl) {
842
+ callCancelEndpoint(currentUploadId, currentBaseUrl).catch(() => {
843
+ });
844
+ }
845
+ internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
846
+ },
847
+ getStatus: () => uploadState
779
848
  };
780
849
  }
781
850
  /**
@@ -815,8 +884,20 @@ var DropgateClient = class {
815
884
  if (!fileId || typeof fileId !== "string") {
816
885
  throw new DropgateValidationError("File ID is required.");
817
886
  }
818
- const baseUrl = buildBaseUrl({ host, port, secure });
819
- progress({ phase: "metadata", text: "Fetching file info...", receivedBytes: 0, totalBytes: 0, percent: 0 });
887
+ progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
888
+ const compat = await this.checkCompatibility({
889
+ host,
890
+ port,
891
+ secure,
892
+ timeoutMs,
893
+ signal
894
+ });
895
+ const { baseUrl } = compat;
896
+ progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
897
+ if (!compat.compatible) {
898
+ throw new DropgateValidationError(compat.message);
899
+ }
900
+ progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
820
901
  const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
821
902
  let metadata;
822
903
  try {
@@ -859,7 +940,7 @@ var DropgateClient = class {
859
940
  if (!this.cryptoObj?.subtle) {
860
941
  throw new DropgateValidationError("Web Crypto API not available for decryption.");
861
942
  }
862
- progress({ phase: "decrypting", text: "Preparing decryption...", receivedBytes: 0, totalBytes: 0, percent: 0 });
943
+ progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
863
944
  try {
864
945
  cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
865
946
  filename = await decryptFilenameFromBase64(
@@ -877,7 +958,7 @@ var DropgateClient = class {
877
958
  } else {
878
959
  filename = metadata.filename || "file";
879
960
  }
880
- progress({ phase: "downloading", text: "Starting download...", percent: 0, receivedBytes: 0, totalBytes });
961
+ progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
881
962
  const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
882
963
  let receivedBytes = 0;
883
964
  const dataChunks = [];
@@ -946,7 +1027,7 @@ var DropgateClient = class {
946
1027
  phase: "decrypting",
947
1028
  text: `Downloading & decrypting... (${percent}%)`,
948
1029
  percent,
949
- receivedBytes,
1030
+ processedBytes: receivedBytes,
950
1031
  totalBytes
951
1032
  });
952
1033
  }
@@ -978,7 +1059,7 @@ var DropgateClient = class {
978
1059
  phase: "downloading",
979
1060
  text: `Downloading... (${percent}%)`,
980
1061
  percent,
981
- receivedBytes,
1062
+ processedBytes: receivedBytes,
982
1063
  totalBytes
983
1064
  });
984
1065
  }
@@ -992,7 +1073,7 @@ var DropgateClient = class {
992
1073
  } finally {
993
1074
  downloadCleanup();
994
1075
  }
995
- progress({ phase: "complete", text: "Download complete!", percent: 100, receivedBytes, totalBytes });
1076
+ progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
996
1077
  let data;
997
1078
  if (collectData && dataChunks.length > 0) {
998
1079
  const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
@@ -1019,7 +1100,9 @@ var DropgateClient = class {
1019
1100
  signal,
1020
1101
  progress,
1021
1102
  chunkIndex,
1022
- totalChunks
1103
+ totalChunks,
1104
+ chunkSize,
1105
+ fileSizeBytes
1023
1106
  } = opts;
1024
1107
  let attemptsLeft = retries;
1025
1108
  let currentBackoff = backoffMs;
@@ -1052,6 +1135,8 @@ var DropgateClient = class {
1052
1135
  throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
1053
1136
  }
1054
1137
  const attemptNumber = maxRetries - attemptsLeft + 1;
1138
+ const processedBytes = chunkIndex * chunkSize;
1139
+ const percent = chunkIndex / totalChunks * 100;
1055
1140
  let remaining = currentBackoff;
1056
1141
  const tick = 100;
1057
1142
  while (remaining > 0) {
@@ -1059,6 +1144,9 @@ var DropgateClient = class {
1059
1144
  progress({
1060
1145
  phase: "retry-wait",
1061
1146
  text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
1147
+ percent,
1148
+ processedBytes,
1149
+ totalBytes: fileSizeBytes,
1062
1150
  chunkIndex,
1063
1151
  totalChunks
1064
1152
  });
@@ -1068,6 +1156,9 @@ var DropgateClient = class {
1068
1156
  progress({
1069
1157
  phase: "retry",
1070
1158
  text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
1159
+ percent,
1160
+ processedBytes,
1161
+ totalBytes: fileSizeBytes,
1071
1162
  chunkIndex,
1072
1163
  totalChunks
1073
1164
  });
@@ -1090,11 +1181,11 @@ function isSecureContextForP2P(hostname, isSecureContext) {
1090
1181
  return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
1091
1182
  }
1092
1183
  function generateP2PCode(cryptoObj) {
1093
- const crypto = cryptoObj || getDefaultCrypto();
1184
+ const crypto2 = cryptoObj || getDefaultCrypto();
1094
1185
  const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
1095
- if (crypto) {
1186
+ if (crypto2) {
1096
1187
  const randomBytes = new Uint8Array(8);
1097
- crypto.getRandomValues(randomBytes);
1188
+ crypto2.getRandomValues(randomBytes);
1098
1189
  let letterPart = "";
1099
1190
  for (let i = 0; i < 4; i++) {
1100
1191
  letterPart += letters[randomBytes[i] % letters.length];
@@ -1120,8 +1211,14 @@ function isP2PCodeLike(code) {
1120
1211
  }
1121
1212
 
1122
1213
  // src/p2p/helpers.ts
1123
- function buildPeerOptions(opts = {}) {
1124
- const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = opts;
1214
+ function resolvePeerConfig(userConfig, serverCaps) {
1215
+ return {
1216
+ path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
1217
+ iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
1218
+ };
1219
+ }
1220
+ function buildPeerOptions(config = {}) {
1221
+ const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
1125
1222
  const peerOpts = {
1126
1223
  host,
1127
1224
  path: peerjsPath,
@@ -1163,6 +1260,12 @@ async function createPeerWithRetries(opts) {
1163
1260
  }
1164
1261
 
1165
1262
  // src/p2p/send.ts
1263
+ function generateSessionId() {
1264
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
1265
+ return crypto.randomUUID();
1266
+ }
1267
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
1268
+ }
1166
1269
  async function startP2PSend(opts) {
1167
1270
  const {
1168
1271
  file,
@@ -1177,15 +1280,17 @@ async function startP2PSend(opts) {
1177
1280
  cryptoObj,
1178
1281
  maxAttempts = 4,
1179
1282
  chunkSize = 256 * 1024,
1180
- readyTimeoutMs = 8e3,
1181
1283
  endAckTimeoutMs = 15e3,
1182
1284
  bufferHighWaterMark = 8 * 1024 * 1024,
1183
1285
  bufferLowWaterMark = 2 * 1024 * 1024,
1286
+ heartbeatIntervalMs = 5e3,
1184
1287
  onCode,
1185
1288
  onStatus,
1186
1289
  onProgress,
1187
1290
  onComplete,
1188
- onError
1291
+ onError,
1292
+ onDisconnect,
1293
+ onCancel
1189
1294
  } = opts;
1190
1295
  if (!file) {
1191
1296
  throw new DropgateValidationError("File is missing.");
@@ -1199,8 +1304,10 @@ async function startP2PSend(opts) {
1199
1304
  if (serverInfo && !p2pCaps?.enabled) {
1200
1305
  throw new DropgateValidationError("Direct transfer is disabled on this server.");
1201
1306
  }
1202
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1203
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1307
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1308
+ { peerjsPath, iceServers },
1309
+ p2pCaps
1310
+ );
1204
1311
  const peerOpts = buildPeerOptions({
1205
1312
  host,
1206
1313
  port,
@@ -1217,18 +1324,37 @@ async function startP2PSend(opts) {
1217
1324
  buildPeer,
1218
1325
  onCode
1219
1326
  });
1220
- let stopped = false;
1327
+ const sessionId = generateSessionId();
1328
+ let state = "listening";
1221
1329
  let activeConn = null;
1222
- let transferActive = false;
1223
- let transferCompleted = false;
1330
+ let sentBytes = 0;
1331
+ let heartbeatTimer = null;
1224
1332
  const reportProgress = (data) => {
1225
1333
  const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
1226
1334
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
1227
1335
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
1228
- onProgress?.({ sent: safeReceived, total: safeTotal, percent });
1336
+ onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1229
1337
  };
1230
- const stop = () => {
1231
- stopped = true;
1338
+ const safeError = (err) => {
1339
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1340
+ state = "closed";
1341
+ onError?.(err);
1342
+ cleanup();
1343
+ };
1344
+ const safeComplete = () => {
1345
+ if (state !== "finishing") return;
1346
+ state = "completed";
1347
+ onComplete?.();
1348
+ cleanup();
1349
+ };
1350
+ const cleanup = () => {
1351
+ if (heartbeatTimer) {
1352
+ clearInterval(heartbeatTimer);
1353
+ heartbeatTimer = null;
1354
+ }
1355
+ if (typeof window !== "undefined") {
1356
+ window.removeEventListener("beforeunload", handleUnload);
1357
+ }
1232
1358
  try {
1233
1359
  activeConn?.close();
1234
1360
  } catch {
@@ -1238,21 +1364,69 @@ async function startP2PSend(opts) {
1238
1364
  } catch {
1239
1365
  }
1240
1366
  };
1367
+ const handleUnload = () => {
1368
+ try {
1369
+ activeConn?.send({ t: "error", message: "Sender closed the connection." });
1370
+ } catch {
1371
+ }
1372
+ stop();
1373
+ };
1374
+ if (typeof window !== "undefined") {
1375
+ window.addEventListener("beforeunload", handleUnload);
1376
+ }
1377
+ const stop = () => {
1378
+ if (state === "closed" || state === "cancelled") return;
1379
+ const wasActive = state === "transferring" || state === "finishing";
1380
+ state = "cancelled";
1381
+ try {
1382
+ if (activeConn && activeConn.open) {
1383
+ activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
1384
+ }
1385
+ } catch {
1386
+ }
1387
+ if (wasActive && onCancel) {
1388
+ onCancel({ cancelledBy: "sender" });
1389
+ }
1390
+ cleanup();
1391
+ };
1392
+ const isStopped = () => state === "closed" || state === "cancelled";
1241
1393
  peer.on("connection", (conn) => {
1242
- if (stopped) return;
1394
+ if (state === "closed") return;
1243
1395
  if (activeConn) {
1244
- try {
1245
- conn.send({ t: "error", message: "Another receiver is already connected." });
1246
- } catch {
1247
- }
1248
- try {
1249
- conn.close();
1250
- } catch {
1396
+ const isOldConnOpen = activeConn.open !== false;
1397
+ if (isOldConnOpen && state === "transferring") {
1398
+ try {
1399
+ conn.send({ t: "error", message: "Transfer already in progress." });
1400
+ } catch {
1401
+ }
1402
+ try {
1403
+ conn.close();
1404
+ } catch {
1405
+ }
1406
+ return;
1407
+ } else if (!isOldConnOpen) {
1408
+ try {
1409
+ activeConn.close();
1410
+ } catch {
1411
+ }
1412
+ activeConn = null;
1413
+ state = "listening";
1414
+ sentBytes = 0;
1415
+ } else {
1416
+ try {
1417
+ conn.send({ t: "error", message: "Another receiver is already connected." });
1418
+ } catch {
1419
+ }
1420
+ try {
1421
+ conn.close();
1422
+ } catch {
1423
+ }
1424
+ return;
1251
1425
  }
1252
- return;
1253
1426
  }
1254
1427
  activeConn = conn;
1255
- onStatus?.({ phase: "connected", message: "Connected. Starting transfer..." });
1428
+ state = "negotiating";
1429
+ onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
1256
1430
  let readyResolve = null;
1257
1431
  let ackResolve = null;
1258
1432
  const readyPromise = new Promise((resolve) => {
@@ -1268,6 +1442,7 @@ async function startP2PSend(opts) {
1268
1442
  const msg = data;
1269
1443
  if (!msg.t) return;
1270
1444
  if (msg.t === "ready") {
1445
+ onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
1271
1446
  readyResolve?.();
1272
1447
  return;
1273
1448
  }
@@ -1279,22 +1454,30 @@ async function startP2PSend(opts) {
1279
1454
  ackResolve?.(msg);
1280
1455
  return;
1281
1456
  }
1457
+ if (msg.t === "pong") {
1458
+ return;
1459
+ }
1282
1460
  if (msg.t === "error") {
1283
- onError?.(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1284
- stop();
1461
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1462
+ return;
1463
+ }
1464
+ if (msg.t === "cancelled") {
1465
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1466
+ state = "cancelled";
1467
+ onCancel?.({ cancelledBy: "receiver", message: msg.message });
1468
+ cleanup();
1285
1469
  }
1286
1470
  });
1287
1471
  conn.on("open", async () => {
1288
1472
  try {
1289
- transferActive = true;
1290
- if (stopped) return;
1473
+ if (isStopped()) return;
1291
1474
  conn.send({
1292
1475
  t: "meta",
1476
+ sessionId,
1293
1477
  name: file.name,
1294
1478
  size: file.size,
1295
1479
  mime: file.type || "application/octet-stream"
1296
1480
  });
1297
- let sent = 0;
1298
1481
  const total = file.size;
1299
1482
  const dc = conn._dc;
1300
1483
  if (dc && Number.isFinite(bufferLowWaterMark)) {
@@ -1303,13 +1486,26 @@ async function startP2PSend(opts) {
1303
1486
  } catch {
1304
1487
  }
1305
1488
  }
1306
- await Promise.race([readyPromise, sleep(readyTimeoutMs).catch(() => null)]);
1489
+ await readyPromise;
1490
+ if (isStopped()) return;
1491
+ if (heartbeatIntervalMs > 0) {
1492
+ heartbeatTimer = setInterval(() => {
1493
+ if (state === "transferring" || state === "finishing") {
1494
+ try {
1495
+ conn.send({ t: "ping" });
1496
+ } catch {
1497
+ }
1498
+ }
1499
+ }, heartbeatIntervalMs);
1500
+ }
1501
+ state = "transferring";
1307
1502
  for (let offset = 0; offset < total; offset += chunkSize) {
1308
- if (stopped) return;
1503
+ if (isStopped()) return;
1309
1504
  const slice = file.slice(offset, offset + chunkSize);
1310
1505
  const buf = await slice.arrayBuffer();
1506
+ if (isStopped()) return;
1311
1507
  conn.send(buf);
1312
- sent += buf.byteLength;
1508
+ sentBytes += buf.byteLength;
1313
1509
  if (dc) {
1314
1510
  while (dc.bufferedAmount > bufferHighWaterMark) {
1315
1511
  await new Promise((resolve) => {
@@ -1329,13 +1525,15 @@ async function startP2PSend(opts) {
1329
1525
  }
1330
1526
  }
1331
1527
  }
1332
- if (stopped) return;
1528
+ if (isStopped()) return;
1529
+ state = "finishing";
1333
1530
  conn.send({ t: "end" });
1334
1531
  const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
1335
1532
  const ackResult = await Promise.race([
1336
1533
  ackPromise,
1337
1534
  sleep(ackTimeoutMs || 15e3).catch(() => null)
1338
1535
  ]);
1536
+ if (isStopped()) return;
1339
1537
  if (!ackResult || typeof ackResult !== "object") {
1340
1538
  throw new DropgateNetworkError("Receiver did not confirm completion.");
1341
1539
  }
@@ -1346,29 +1544,43 @@ async function startP2PSend(opts) {
1346
1544
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
1347
1545
  }
1348
1546
  reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
1349
- transferCompleted = true;
1350
- transferActive = false;
1351
- onComplete?.();
1352
- stop();
1547
+ safeComplete();
1353
1548
  } catch (err) {
1354
- onError?.(err);
1355
- stop();
1549
+ safeError(err);
1356
1550
  }
1357
1551
  });
1358
1552
  conn.on("error", (err) => {
1359
- onError?.(err);
1360
- stop();
1553
+ safeError(err);
1361
1554
  });
1362
1555
  conn.on("close", () => {
1363
- if (!transferCompleted && transferActive && !stopped) {
1364
- onError?.(
1365
- new DropgateNetworkError("Receiver disconnected before transfer completed.")
1366
- );
1556
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1557
+ cleanup();
1558
+ return;
1559
+ }
1560
+ if (state === "transferring" || state === "finishing") {
1561
+ state = "cancelled";
1562
+ onCancel?.({ cancelledBy: "receiver" });
1563
+ cleanup();
1564
+ } else {
1565
+ activeConn = null;
1566
+ state = "listening";
1567
+ sentBytes = 0;
1568
+ onDisconnect?.();
1367
1569
  }
1368
- stop();
1369
1570
  });
1370
1571
  });
1371
- return { peer, code, stop };
1572
+ return {
1573
+ peer,
1574
+ code,
1575
+ sessionId,
1576
+ stop,
1577
+ getStatus: () => state,
1578
+ getBytesSent: () => sentBytes,
1579
+ getConnectedPeerId: () => {
1580
+ if (!activeConn) return null;
1581
+ return activeConn.peer || null;
1582
+ }
1583
+ };
1372
1584
  }
1373
1585
 
1374
1586
  // src/p2p/receive.ts
@@ -1382,13 +1594,16 @@ async function startP2PReceive(opts) {
1382
1594
  peerjsPath,
1383
1595
  secure = false,
1384
1596
  iceServers,
1597
+ autoReady = true,
1598
+ watchdogTimeoutMs = 15e3,
1385
1599
  onStatus,
1386
1600
  onMeta,
1387
1601
  onData,
1388
1602
  onProgress,
1389
1603
  onComplete,
1390
1604
  onError,
1391
- onDisconnect
1605
+ onDisconnect,
1606
+ onCancel
1392
1607
  } = opts;
1393
1608
  if (!code) {
1394
1609
  throw new DropgateValidationError("No sharing code was provided.");
@@ -1406,8 +1621,10 @@ async function startP2PReceive(opts) {
1406
1621
  if (!isP2PCodeLike(normalizedCode)) {
1407
1622
  throw new DropgateValidationError("Invalid direct transfer code.");
1408
1623
  }
1409
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1410
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1624
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1625
+ { peerjsPath, iceServers },
1626
+ p2pCaps
1627
+ );
1411
1628
  const peerOpts = buildPeerOptions({
1412
1629
  host,
1413
1630
  port,
@@ -1416,44 +1633,137 @@ async function startP2PReceive(opts) {
1416
1633
  iceServers: finalIceServers
1417
1634
  });
1418
1635
  const peer = new Peer(void 0, peerOpts);
1636
+ let state = "initializing";
1419
1637
  let total = 0;
1420
1638
  let received = 0;
1639
+ let currentSessionId = null;
1421
1640
  let lastProgressSentAt = 0;
1422
1641
  const progressIntervalMs = 120;
1423
1642
  let writeQueue = Promise.resolve();
1424
- const stop = () => {
1643
+ let watchdogTimer = null;
1644
+ let activeConn = null;
1645
+ const resetWatchdog = () => {
1646
+ if (watchdogTimeoutMs <= 0) return;
1647
+ if (watchdogTimer) {
1648
+ clearTimeout(watchdogTimer);
1649
+ }
1650
+ watchdogTimer = setTimeout(() => {
1651
+ if (state === "transferring") {
1652
+ safeError(new DropgateNetworkError("Connection timed out (no data received)."));
1653
+ }
1654
+ }, watchdogTimeoutMs);
1655
+ };
1656
+ const clearWatchdog = () => {
1657
+ if (watchdogTimer) {
1658
+ clearTimeout(watchdogTimer);
1659
+ watchdogTimer = null;
1660
+ }
1661
+ };
1662
+ const safeError = (err) => {
1663
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1664
+ state = "closed";
1665
+ onError?.(err);
1666
+ cleanup();
1667
+ };
1668
+ const safeComplete = (completeData) => {
1669
+ if (state !== "transferring") return;
1670
+ state = "completed";
1671
+ onComplete?.(completeData);
1672
+ cleanup();
1673
+ };
1674
+ const cleanup = () => {
1675
+ clearWatchdog();
1676
+ if (typeof window !== "undefined") {
1677
+ window.removeEventListener("beforeunload", handleUnload);
1678
+ }
1425
1679
  try {
1426
1680
  peer.destroy();
1427
1681
  } catch {
1428
1682
  }
1429
1683
  };
1430
- peer.on("error", (err) => {
1431
- onError?.(err);
1684
+ const handleUnload = () => {
1685
+ try {
1686
+ activeConn?.send({ t: "error", message: "Receiver closed the connection." });
1687
+ } catch {
1688
+ }
1432
1689
  stop();
1690
+ };
1691
+ if (typeof window !== "undefined") {
1692
+ window.addEventListener("beforeunload", handleUnload);
1693
+ }
1694
+ const stop = () => {
1695
+ if (state === "closed" || state === "cancelled") return;
1696
+ const wasActive = state === "transferring";
1697
+ state = "cancelled";
1698
+ try {
1699
+ if (activeConn && activeConn.open) {
1700
+ activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
1701
+ }
1702
+ } catch {
1703
+ }
1704
+ if (wasActive && onCancel) {
1705
+ onCancel({ cancelledBy: "receiver" });
1706
+ }
1707
+ cleanup();
1708
+ };
1709
+ peer.on("error", (err) => {
1710
+ safeError(err);
1433
1711
  });
1434
1712
  peer.on("open", () => {
1713
+ state = "connecting";
1435
1714
  const conn = peer.connect(normalizedCode, { reliable: true });
1715
+ activeConn = conn;
1436
1716
  conn.on("open", () => {
1717
+ state = "negotiating";
1437
1718
  onStatus?.({ phase: "connected", message: "Waiting for file details..." });
1438
1719
  });
1439
1720
  conn.on("data", async (data) => {
1440
1721
  try {
1722
+ resetWatchdog();
1441
1723
  if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
1442
1724
  const msg = data;
1443
1725
  if (msg.t === "meta") {
1726
+ if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
1727
+ try {
1728
+ conn.send({ t: "error", message: "Busy with another session." });
1729
+ } catch {
1730
+ }
1731
+ return;
1732
+ }
1733
+ if (msg.sessionId) {
1734
+ currentSessionId = msg.sessionId;
1735
+ }
1444
1736
  const name = String(msg.name || "file");
1445
1737
  total = Number(msg.size) || 0;
1446
1738
  received = 0;
1447
1739
  writeQueue = Promise.resolve();
1448
- onMeta?.({ name, total });
1449
- onProgress?.({ received, total, percent: 0 });
1740
+ const sendReady = () => {
1741
+ state = "transferring";
1742
+ resetWatchdog();
1743
+ try {
1744
+ conn.send({ t: "ready" });
1745
+ } catch {
1746
+ }
1747
+ };
1748
+ if (autoReady) {
1749
+ onMeta?.({ name, total });
1750
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1751
+ sendReady();
1752
+ } else {
1753
+ onMeta?.({ name, total, sendReady });
1754
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1755
+ }
1756
+ return;
1757
+ }
1758
+ if (msg.t === "ping") {
1450
1759
  try {
1451
- conn.send({ t: "ready" });
1760
+ conn.send({ t: "pong" });
1452
1761
  } catch {
1453
1762
  }
1454
1763
  return;
1455
1764
  }
1456
1765
  if (msg.t === "end") {
1766
+ clearWatchdog();
1457
1767
  await writeQueue;
1458
1768
  if (total && received < total) {
1459
1769
  const err = new DropgateNetworkError(
@@ -1465,16 +1775,23 @@ async function startP2PReceive(opts) {
1465
1775
  }
1466
1776
  throw err;
1467
1777
  }
1468
- onComplete?.({ received, total });
1469
1778
  try {
1470
1779
  conn.send({ t: "ack", phase: "end", received, total });
1471
1780
  } catch {
1472
1781
  }
1782
+ safeComplete({ received, total });
1473
1783
  return;
1474
1784
  }
1475
1785
  if (msg.t === "error") {
1476
1786
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
1477
1787
  }
1788
+ if (msg.t === "cancelled") {
1789
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1790
+ state = "cancelled";
1791
+ onCancel?.({ cancelledBy: "sender", message: msg.message });
1792
+ cleanup();
1793
+ return;
1794
+ }
1478
1795
  return;
1479
1796
  }
1480
1797
  let bufPromise;
@@ -1496,7 +1813,7 @@ async function startP2PReceive(opts) {
1496
1813
  }
1497
1814
  received += buf.byteLength;
1498
1815
  const percent = total ? Math.min(100, received / total * 100) : 0;
1499
- onProgress?.({ received, total, percent });
1816
+ onProgress?.({ processedBytes: received, totalBytes: total, percent });
1500
1817
  const now = Date.now();
1501
1818
  if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
1502
1819
  lastProgressSentAt = now;
@@ -1513,21 +1830,38 @@ async function startP2PReceive(opts) {
1513
1830
  });
1514
1831
  } catch {
1515
1832
  }
1516
- onError?.(err);
1517
- stop();
1833
+ safeError(err);
1518
1834
  });
1519
1835
  } catch (err) {
1520
- onError?.(err);
1521
- stop();
1836
+ safeError(err);
1522
1837
  }
1523
1838
  });
1524
1839
  conn.on("close", () => {
1525
- if (received > 0 && total > 0 && received < total) {
1840
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1841
+ cleanup();
1842
+ return;
1843
+ }
1844
+ if (state === "transferring") {
1845
+ state = "cancelled";
1846
+ onCancel?.({ cancelledBy: "sender" });
1847
+ cleanup();
1848
+ } else if (state === "negotiating") {
1849
+ state = "closed";
1850
+ cleanup();
1526
1851
  onDisconnect?.();
1852
+ } else {
1853
+ safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
1527
1854
  }
1528
1855
  });
1529
1856
  });
1530
- return { peer, stop };
1857
+ return {
1858
+ peer,
1859
+ stop,
1860
+ getStatus: () => state,
1861
+ getBytesReceived: () => received,
1862
+ getTotalBytes: () => total,
1863
+ getSessionId: () => currentSessionId
1864
+ };
1531
1865
  }
1532
1866
  // Annotate the CommonJS export names for ESM import in node:
1533
1867
  0 && (module.exports = {
@@ -1560,6 +1894,7 @@ async function startP2PReceive(opts) {
1560
1894
  getDefaultBase64,
1561
1895
  getDefaultCrypto,
1562
1896
  getDefaultFetch,
1897
+ getServerInfo,
1563
1898
  importKeyFromBase64,
1564
1899
  isLocalhostHostname,
1565
1900
  isP2PCodeLike,
@@ -1568,6 +1903,7 @@ async function startP2PReceive(opts) {
1568
1903
  makeAbortSignal,
1569
1904
  parseSemverMajorMinor,
1570
1905
  parseServerUrl,
1906
+ resolvePeerConfig,
1571
1907
  sha256Hex,
1572
1908
  sleep,
1573
1909
  startP2PReceive,