@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.js CHANGED
@@ -309,6 +309,37 @@ function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
309
309
  if (!isEncrypted) return base;
310
310
  return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
311
311
  }
312
+ async function getServerInfo(opts) {
313
+ const { host, port, secure, timeoutMs = 5e3, signal, fetchFn: customFetch } = opts;
314
+ const fetchFn = customFetch || getDefaultFetch();
315
+ if (!fetchFn) {
316
+ throw new DropgateValidationError("No fetch() implementation found.");
317
+ }
318
+ const baseUrl = buildBaseUrl({ host, port, secure });
319
+ try {
320
+ const { res, json } = await fetchJson(
321
+ fetchFn,
322
+ `${baseUrl}/api/info`,
323
+ {
324
+ method: "GET",
325
+ timeoutMs,
326
+ signal,
327
+ headers: { Accept: "application/json" }
328
+ }
329
+ );
330
+ if (res.ok && json && typeof json === "object" && "version" in json) {
331
+ return { baseUrl, serverInfo: json };
332
+ }
333
+ throw new DropgateProtocolError(
334
+ `Server info request failed (status ${res.status}).`
335
+ );
336
+ } catch (err) {
337
+ if (err instanceof DropgateError) throw err;
338
+ throw new DropgateNetworkError("Could not reach server /api/info.", {
339
+ cause: err
340
+ });
341
+ }
342
+ }
312
343
  var DropgateClient = class {
313
344
  /**
314
345
  * Create a new DropgateClient instance.
@@ -336,40 +367,6 @@ var DropgateClient = class {
336
367
  this.base64 = opts.base64 || getDefaultBase64();
337
368
  this.logger = opts.logger || null;
338
369
  }
339
- /**
340
- * Fetch server information from the /api/info endpoint.
341
- * @param opts - Server target and request options.
342
- * @returns The server base URL and server info object.
343
- * @throws {DropgateNetworkError} If the server cannot be reached.
344
- * @throws {DropgateProtocolError} If the server returns an invalid response.
345
- */
346
- async getServerInfo(opts) {
347
- const { host, port, secure, timeoutMs = 5e3, signal } = opts;
348
- const baseUrl = buildBaseUrl({ host, port, secure });
349
- try {
350
- const { res, json } = await fetchJson(
351
- this.fetchFn,
352
- `${baseUrl}/api/info`,
353
- {
354
- method: "GET",
355
- timeoutMs,
356
- signal,
357
- headers: { Accept: "application/json" }
358
- }
359
- );
360
- if (res.ok && json && typeof json === "object" && "version" in json) {
361
- return { baseUrl, serverInfo: json };
362
- }
363
- throw new DropgateProtocolError(
364
- `Server info request failed (status ${res.status}).`
365
- );
366
- } catch (err) {
367
- if (err instanceof DropgateError) throw err;
368
- throw new DropgateNetworkError("Could not reach server /api/info.", {
369
- cause: err
370
- });
371
- }
372
- }
373
370
  /**
374
371
  * Resolve a user-entered sharing code or URL via the server.
375
372
  * @param value - The sharing code or URL to resolve.
@@ -378,8 +375,12 @@ var DropgateClient = class {
378
375
  * @throws {DropgateProtocolError} If the share lookup fails.
379
376
  */
380
377
  async resolveShareTarget(value, opts) {
381
- const { host, port, secure, timeoutMs = 5e3, signal } = opts;
382
- const baseUrl = buildBaseUrl({ host, port, secure });
378
+ const { timeoutMs = 5e3, signal } = opts;
379
+ const compat = await this.checkCompatibility(opts);
380
+ if (!compat.compatible) {
381
+ throw new DropgateValidationError(compat.message);
382
+ }
383
+ const { baseUrl } = compat;
383
384
  const { res, json } = await fetchJson(
384
385
  this.fetchFn,
385
386
  `${baseUrl}/api/resolve`,
@@ -402,10 +403,25 @@ var DropgateClient = class {
402
403
  }
403
404
  /**
404
405
  * Check version compatibility between this client and a server.
405
- * @param serverInfo - Server info containing the version to check against.
406
- * @returns Compatibility result with status and message.
406
+ * Fetches server info internally using getServerInfo.
407
+ * @param opts - Server target and request options.
408
+ * @returns Compatibility result with status, message, and server info.
409
+ * @throws {DropgateNetworkError} If the server cannot be reached.
410
+ * @throws {DropgateProtocolError} If the server returns an invalid response.
407
411
  */
408
- checkCompatibility(serverInfo) {
412
+ async checkCompatibility(opts) {
413
+ let baseUrl;
414
+ let serverInfo;
415
+ try {
416
+ const result = await getServerInfo({ ...opts, fetchFn: this.fetchFn });
417
+ baseUrl = result.baseUrl;
418
+ serverInfo = result.serverInfo;
419
+ } catch (err) {
420
+ if (err instanceof DropgateError) throw err;
421
+ throw new DropgateNetworkError("Could not connect to the server.", {
422
+ cause: err
423
+ });
424
+ }
409
425
  const serverVersion = String(serverInfo?.version || "0.0.0");
410
426
  const clientVersion = String(this.clientVersion || "0.0.0");
411
427
  const c = parseSemverMajorMinor(clientVersion);
@@ -415,7 +431,9 @@ var DropgateClient = class {
415
431
  compatible: false,
416
432
  clientVersion,
417
433
  serverVersion,
418
- message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
434
+ message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
435
+ serverInfo,
436
+ baseUrl
419
437
  };
420
438
  }
421
439
  if (c.minor > s.minor) {
@@ -423,14 +441,18 @@ var DropgateClient = class {
423
441
  compatible: true,
424
442
  clientVersion,
425
443
  serverVersion,
426
- message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`
444
+ message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`,
445
+ serverInfo,
446
+ baseUrl
427
447
  };
428
448
  }
429
449
  return {
430
450
  compatible: true,
431
451
  clientVersion,
432
452
  serverVersion,
433
- message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
453
+ message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
454
+ serverInfo,
455
+ baseUrl
434
456
  };
435
457
  }
436
458
  /**
@@ -509,206 +531,251 @@ var DropgateClient = class {
509
531
  encrypt,
510
532
  filenameOverride,
511
533
  onProgress,
534
+ onCancel,
512
535
  signal,
513
536
  timeouts = {},
514
537
  retry = {}
515
538
  } = opts;
516
- const progress = (evt) => {
517
- try {
518
- if (onProgress) onProgress(evt);
519
- } catch {
520
- }
521
- };
522
- if (!this.cryptoObj?.subtle) {
523
- throw new DropgateValidationError(
524
- "Web Crypto API not available (crypto.subtle)."
525
- );
526
- }
527
- progress({ phase: "server-info", text: "Checking server..." });
528
- let baseUrl;
529
- let serverInfo;
530
- try {
531
- const res = await this.getServerInfo({
532
- host,
533
- port,
534
- secure,
535
- timeoutMs: timeouts.serverInfoMs ?? 5e3,
536
- signal
537
- });
538
- baseUrl = res.baseUrl;
539
- serverInfo = res.serverInfo;
540
- } catch (err) {
541
- if (err instanceof DropgateError) throw err;
542
- throw new DropgateNetworkError("Could not connect to the server.", {
543
- cause: err
544
- });
545
- }
546
- const compat = this.checkCompatibility(serverInfo);
547
- progress({ phase: "server-compat", text: compat.message });
548
- if (!compat.compatible) {
549
- throw new DropgateValidationError(compat.message);
550
- }
551
- const filename = filenameOverride ?? file.name ?? "file";
552
- if (!encrypt) {
553
- validatePlainFilename(filename);
554
- }
555
- this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
556
- let cryptoKey = null;
557
- let keyB64 = null;
558
- let transmittedFilename = filename;
559
- if (encrypt) {
560
- progress({ phase: "crypto", text: "Generating encryption key..." });
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 () => {
561
545
  try {
562
- cryptoKey = await generateAesGcmKey(this.cryptoObj);
563
- keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
564
- transmittedFilename = await encryptFilenameToBase64(
565
- this.cryptoObj,
566
- filename,
567
- 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
+ if (!encrypt) {
573
+ validatePlainFilename(filename);
574
+ }
575
+ this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
576
+ let cryptoKey = null;
577
+ let keyB64 = null;
578
+ let transmittedFilename = filename;
579
+ if (encrypt) {
580
+ progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
581
+ try {
582
+ cryptoKey = await generateAesGcmKey(this.cryptoObj);
583
+ keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
584
+ transmittedFilename = await encryptFilenameToBase64(
585
+ this.cryptoObj,
586
+ filename,
587
+ cryptoKey
588
+ );
589
+ } catch (err) {
590
+ throw new DropgateError("Failed to prepare encryption.", {
591
+ code: "CRYPTO_PREP_FAILED",
592
+ cause: err
593
+ });
594
+ }
595
+ }
596
+ const totalChunks = Math.ceil(file.size / this.chunkSize);
597
+ const totalUploadSize = estimateTotalUploadSizeBytes(
598
+ file.size,
599
+ totalChunks,
600
+ encrypt
568
601
  );
569
- } catch (err) {
570
- throw new DropgateError("Failed to prepare encryption.", {
571
- code: "CRYPTO_PREP_FAILED",
572
- cause: err
602
+ progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
603
+ const initPayload = {
604
+ filename: transmittedFilename,
605
+ lifetime: lifetimeMs,
606
+ isEncrypted: Boolean(encrypt),
607
+ totalSize: totalUploadSize,
608
+ totalChunks
609
+ };
610
+ const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
611
+ method: "POST",
612
+ timeoutMs: timeouts.initMs ?? 15e3,
613
+ signal: effectiveSignal,
614
+ headers: {
615
+ "Content-Type": "application/json",
616
+ Accept: "application/json"
617
+ },
618
+ body: JSON.stringify(initPayload)
573
619
  });
574
- }
575
- }
576
- const totalChunks = Math.ceil(file.size / this.chunkSize);
577
- const totalUploadSize = estimateTotalUploadSizeBytes(
578
- file.size,
579
- totalChunks,
580
- encrypt
581
- );
582
- progress({ phase: "init", text: "Reserving server storage..." });
583
- const initPayload = {
584
- filename: transmittedFilename,
585
- lifetime: lifetimeMs,
586
- isEncrypted: Boolean(encrypt),
587
- totalSize: totalUploadSize,
588
- totalChunks
589
- };
590
- const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
591
- method: "POST",
592
- timeoutMs: timeouts.initMs ?? 15e3,
593
- signal,
594
- headers: {
595
- "Content-Type": "application/json",
596
- Accept: "application/json"
597
- },
598
- body: JSON.stringify(initPayload)
599
- });
600
- if (!initRes.res.ok) {
601
- const errorJson = initRes.json;
602
- const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
603
- throw new DropgateProtocolError(msg, {
604
- details: initRes.json || initRes.text
605
- });
606
- }
607
- const initJson = initRes.json;
608
- const uploadId = initJson?.uploadId;
609
- if (!uploadId || typeof uploadId !== "string") {
610
- throw new DropgateProtocolError(
611
- "Server did not return a valid uploadId."
612
- );
613
- }
614
- const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
615
- const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
616
- const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
617
- for (let i = 0; i < totalChunks; i++) {
618
- if (signal?.aborted) {
619
- throw signal.reason || new DropgateAbortError();
620
- }
621
- const start = i * this.chunkSize;
622
- const end = Math.min(start + this.chunkSize, file.size);
623
- let chunkBlob = file.slice(start, end);
624
- const percentComplete = i / totalChunks * 100;
625
- progress({
626
- phase: "chunk",
627
- text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
628
- percent: percentComplete,
629
- chunkIndex: i,
630
- totalChunks
631
- });
632
- const chunkBuffer = await chunkBlob.arrayBuffer();
633
- let uploadBlob;
634
- if (encrypt && cryptoKey) {
635
- uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
636
- } else {
637
- uploadBlob = new Blob([chunkBuffer]);
638
- }
639
- if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
640
- throw new DropgateValidationError(
641
- "Chunk too large (client-side). Check chunk size settings."
620
+ if (!initRes.res.ok) {
621
+ const errorJson = initRes.json;
622
+ const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
623
+ throw new DropgateProtocolError(msg, {
624
+ details: initRes.json || initRes.text
625
+ });
626
+ }
627
+ const initJson = initRes.json;
628
+ const uploadId = initJson?.uploadId;
629
+ if (!uploadId || typeof uploadId !== "string") {
630
+ throw new DropgateProtocolError(
631
+ "Server did not return a valid uploadId."
632
+ );
633
+ }
634
+ currentUploadId = uploadId;
635
+ currentBaseUrl = baseUrl;
636
+ uploadState = "uploading";
637
+ const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
638
+ const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
639
+ const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
640
+ for (let i = 0; i < totalChunks; i++) {
641
+ if (effectiveSignal?.aborted) {
642
+ throw effectiveSignal.reason || new DropgateAbortError();
643
+ }
644
+ const start = i * this.chunkSize;
645
+ const end = Math.min(start + this.chunkSize, file.size);
646
+ let chunkBlob = file.slice(start, end);
647
+ const percentComplete = i / totalChunks * 100;
648
+ const processedBytes = i * this.chunkSize;
649
+ progress({
650
+ phase: "chunk",
651
+ text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
652
+ percent: percentComplete,
653
+ processedBytes,
654
+ totalBytes: fileSizeBytes,
655
+ chunkIndex: i,
656
+ totalChunks
657
+ });
658
+ const chunkBuffer = await chunkBlob.arrayBuffer();
659
+ let uploadBlob;
660
+ if (encrypt && cryptoKey) {
661
+ uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
662
+ } else {
663
+ uploadBlob = new Blob([chunkBuffer]);
664
+ }
665
+ if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
666
+ throw new DropgateValidationError(
667
+ "Chunk too large (client-side). Check chunk size settings."
668
+ );
669
+ }
670
+ const toHash = await uploadBlob.arrayBuffer();
671
+ const hashHex = await sha256Hex(this.cryptoObj, toHash);
672
+ const headers = {
673
+ "Content-Type": "application/octet-stream",
674
+ "X-Upload-ID": uploadId,
675
+ "X-Chunk-Index": String(i),
676
+ "X-Chunk-Hash": hashHex
677
+ };
678
+ const chunkUrl = `${baseUrl}/upload/chunk`;
679
+ await this.attemptChunkUpload(
680
+ chunkUrl,
681
+ {
682
+ method: "POST",
683
+ headers,
684
+ body: uploadBlob
685
+ },
686
+ {
687
+ retries,
688
+ backoffMs: baseBackoffMs,
689
+ maxBackoffMs,
690
+ timeoutMs: timeouts.chunkMs ?? 6e4,
691
+ signal: effectiveSignal,
692
+ progress,
693
+ chunkIndex: i,
694
+ totalChunks,
695
+ chunkSize: this.chunkSize,
696
+ fileSizeBytes
697
+ }
698
+ );
699
+ }
700
+ progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
701
+ uploadState = "completing";
702
+ const completeRes = await fetchJson(
703
+ this.fetchFn,
704
+ `${baseUrl}/upload/complete`,
705
+ {
706
+ method: "POST",
707
+ timeoutMs: timeouts.completeMs ?? 3e4,
708
+ signal: effectiveSignal,
709
+ headers: {
710
+ "Content-Type": "application/json",
711
+ Accept: "application/json"
712
+ },
713
+ body: JSON.stringify({ uploadId })
714
+ }
642
715
  );
716
+ if (!completeRes.res.ok) {
717
+ const errorJson = completeRes.json;
718
+ const msg = errorJson?.error || "Finalisation failed.";
719
+ throw new DropgateProtocolError(msg, {
720
+ details: completeRes.json || completeRes.text
721
+ });
722
+ }
723
+ const completeJson = completeRes.json;
724
+ const fileId = completeJson?.id;
725
+ if (!fileId || typeof fileId !== "string") {
726
+ throw new DropgateProtocolError(
727
+ "Server did not return a valid file id."
728
+ );
729
+ }
730
+ let downloadUrl = `${baseUrl}/${fileId}`;
731
+ if (encrypt && keyB64) {
732
+ downloadUrl += `#${keyB64}`;
733
+ }
734
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
735
+ uploadState = "completed";
736
+ return {
737
+ downloadUrl,
738
+ fileId,
739
+ uploadId,
740
+ baseUrl,
741
+ ...encrypt && keyB64 ? { keyB64 } : {}
742
+ };
743
+ } catch (err) {
744
+ if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
745
+ uploadState = "cancelled";
746
+ onCancel?.();
747
+ } else {
748
+ uploadState = "error";
749
+ }
750
+ throw err;
643
751
  }
644
- const toHash = await uploadBlob.arrayBuffer();
645
- const hashHex = await sha256Hex(this.cryptoObj, toHash);
646
- const headers = {
647
- "Content-Type": "application/octet-stream",
648
- "X-Upload-ID": uploadId,
649
- "X-Chunk-Index": String(i),
650
- "X-Chunk-Hash": hashHex
651
- };
652
- const chunkUrl = `${baseUrl}/upload/chunk`;
653
- await this.attemptChunkUpload(
654
- chunkUrl,
655
- {
752
+ })();
753
+ const callCancelEndpoint = async (uploadId, baseUrl) => {
754
+ try {
755
+ await fetchJson(this.fetchFn, `${baseUrl}/upload/cancel`, {
656
756
  method: "POST",
657
- headers,
658
- body: uploadBlob
659
- },
660
- {
661
- retries,
662
- backoffMs: baseBackoffMs,
663
- maxBackoffMs,
664
- timeoutMs: timeouts.chunkMs ?? 6e4,
665
- signal,
666
- progress,
667
- chunkIndex: i,
668
- totalChunks
669
- }
670
- );
671
- }
672
- progress({ phase: "complete", text: "Finalising upload...", percent: 100 });
673
- const completeRes = await fetchJson(
674
- this.fetchFn,
675
- `${baseUrl}/upload/complete`,
676
- {
677
- method: "POST",
678
- timeoutMs: timeouts.completeMs ?? 3e4,
679
- signal,
680
- headers: {
681
- "Content-Type": "application/json",
682
- Accept: "application/json"
683
- },
684
- body: JSON.stringify({ uploadId })
757
+ timeoutMs: 5e3,
758
+ headers: {
759
+ "Content-Type": "application/json",
760
+ Accept: "application/json"
761
+ },
762
+ body: JSON.stringify({ uploadId })
763
+ });
764
+ } catch {
685
765
  }
686
- );
687
- if (!completeRes.res.ok) {
688
- const errorJson = completeRes.json;
689
- const msg = errorJson?.error || "Finalisation failed.";
690
- throw new DropgateProtocolError(msg, {
691
- details: completeRes.json || completeRes.text
692
- });
693
- }
694
- const completeJson = completeRes.json;
695
- const fileId = completeJson?.id;
696
- if (!fileId || typeof fileId !== "string") {
697
- throw new DropgateProtocolError(
698
- "Server did not return a valid file id."
699
- );
700
- }
701
- let downloadUrl = `${baseUrl}/${fileId}`;
702
- if (encrypt && keyB64) {
703
- downloadUrl += `#${keyB64}`;
704
- }
705
- progress({ phase: "done", text: "Upload successful!", percent: 100 });
766
+ };
706
767
  return {
707
- downloadUrl,
708
- fileId,
709
- uploadId,
710
- baseUrl,
711
- ...encrypt && keyB64 ? { keyB64 } : {}
768
+ result: uploadPromise,
769
+ cancel: (reason) => {
770
+ if (uploadState === "completed" || uploadState === "cancelled") return;
771
+ uploadState = "cancelled";
772
+ if (currentUploadId && currentBaseUrl) {
773
+ callCancelEndpoint(currentUploadId, currentBaseUrl).catch(() => {
774
+ });
775
+ }
776
+ internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
777
+ },
778
+ getStatus: () => uploadState
712
779
  };
713
780
  }
714
781
  /**
@@ -748,8 +815,20 @@ var DropgateClient = class {
748
815
  if (!fileId || typeof fileId !== "string") {
749
816
  throw new DropgateValidationError("File ID is required.");
750
817
  }
751
- const baseUrl = buildBaseUrl({ host, port, secure });
752
- progress({ phase: "metadata", text: "Fetching file info...", receivedBytes: 0, totalBytes: 0, percent: 0 });
818
+ progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
819
+ const compat = await this.checkCompatibility({
820
+ host,
821
+ port,
822
+ secure,
823
+ timeoutMs,
824
+ signal
825
+ });
826
+ const { baseUrl } = compat;
827
+ progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
828
+ if (!compat.compatible) {
829
+ throw new DropgateValidationError(compat.message);
830
+ }
831
+ progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
753
832
  const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
754
833
  let metadata;
755
834
  try {
@@ -792,7 +871,7 @@ var DropgateClient = class {
792
871
  if (!this.cryptoObj?.subtle) {
793
872
  throw new DropgateValidationError("Web Crypto API not available for decryption.");
794
873
  }
795
- progress({ phase: "decrypting", text: "Preparing decryption...", receivedBytes: 0, totalBytes: 0, percent: 0 });
874
+ progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
796
875
  try {
797
876
  cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
798
877
  filename = await decryptFilenameFromBase64(
@@ -810,7 +889,7 @@ var DropgateClient = class {
810
889
  } else {
811
890
  filename = metadata.filename || "file";
812
891
  }
813
- progress({ phase: "downloading", text: "Starting download...", percent: 0, receivedBytes: 0, totalBytes });
892
+ progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
814
893
  const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
815
894
  let receivedBytes = 0;
816
895
  const dataChunks = [];
@@ -879,7 +958,7 @@ var DropgateClient = class {
879
958
  phase: "decrypting",
880
959
  text: `Downloading & decrypting... (${percent}%)`,
881
960
  percent,
882
- receivedBytes,
961
+ processedBytes: receivedBytes,
883
962
  totalBytes
884
963
  });
885
964
  }
@@ -911,7 +990,7 @@ var DropgateClient = class {
911
990
  phase: "downloading",
912
991
  text: `Downloading... (${percent}%)`,
913
992
  percent,
914
- receivedBytes,
993
+ processedBytes: receivedBytes,
915
994
  totalBytes
916
995
  });
917
996
  }
@@ -925,7 +1004,7 @@ var DropgateClient = class {
925
1004
  } finally {
926
1005
  downloadCleanup();
927
1006
  }
928
- progress({ phase: "complete", text: "Download complete!", percent: 100, receivedBytes, totalBytes });
1007
+ progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
929
1008
  let data;
930
1009
  if (collectData && dataChunks.length > 0) {
931
1010
  const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
@@ -952,7 +1031,9 @@ var DropgateClient = class {
952
1031
  signal,
953
1032
  progress,
954
1033
  chunkIndex,
955
- totalChunks
1034
+ totalChunks,
1035
+ chunkSize,
1036
+ fileSizeBytes
956
1037
  } = opts;
957
1038
  let attemptsLeft = retries;
958
1039
  let currentBackoff = backoffMs;
@@ -985,6 +1066,8 @@ var DropgateClient = class {
985
1066
  throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
986
1067
  }
987
1068
  const attemptNumber = maxRetries - attemptsLeft + 1;
1069
+ const processedBytes = chunkIndex * chunkSize;
1070
+ const percent = chunkIndex / totalChunks * 100;
988
1071
  let remaining = currentBackoff;
989
1072
  const tick = 100;
990
1073
  while (remaining > 0) {
@@ -992,6 +1075,9 @@ var DropgateClient = class {
992
1075
  progress({
993
1076
  phase: "retry-wait",
994
1077
  text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
1078
+ percent,
1079
+ processedBytes,
1080
+ totalBytes: fileSizeBytes,
995
1081
  chunkIndex,
996
1082
  totalChunks
997
1083
  });
@@ -1001,6 +1087,9 @@ var DropgateClient = class {
1001
1087
  progress({
1002
1088
  phase: "retry",
1003
1089
  text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
1090
+ percent,
1091
+ processedBytes,
1092
+ totalBytes: fileSizeBytes,
1004
1093
  chunkIndex,
1005
1094
  totalChunks
1006
1095
  });
@@ -1023,11 +1112,11 @@ function isSecureContextForP2P(hostname, isSecureContext) {
1023
1112
  return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
1024
1113
  }
1025
1114
  function generateP2PCode(cryptoObj) {
1026
- const crypto = cryptoObj || getDefaultCrypto();
1115
+ const crypto2 = cryptoObj || getDefaultCrypto();
1027
1116
  const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
1028
- if (crypto) {
1117
+ if (crypto2) {
1029
1118
  const randomBytes = new Uint8Array(8);
1030
- crypto.getRandomValues(randomBytes);
1119
+ crypto2.getRandomValues(randomBytes);
1031
1120
  let letterPart = "";
1032
1121
  for (let i = 0; i < 4; i++) {
1033
1122
  letterPart += letters[randomBytes[i] % letters.length];
@@ -1053,8 +1142,14 @@ function isP2PCodeLike(code) {
1053
1142
  }
1054
1143
 
1055
1144
  // src/p2p/helpers.ts
1056
- function buildPeerOptions(opts = {}) {
1057
- const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = opts;
1145
+ function resolvePeerConfig(userConfig, serverCaps) {
1146
+ return {
1147
+ path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
1148
+ iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
1149
+ };
1150
+ }
1151
+ function buildPeerOptions(config = {}) {
1152
+ const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
1058
1153
  const peerOpts = {
1059
1154
  host,
1060
1155
  path: peerjsPath,
@@ -1096,6 +1191,12 @@ async function createPeerWithRetries(opts) {
1096
1191
  }
1097
1192
 
1098
1193
  // src/p2p/send.ts
1194
+ function generateSessionId() {
1195
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
1196
+ return crypto.randomUUID();
1197
+ }
1198
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
1199
+ }
1099
1200
  async function startP2PSend(opts) {
1100
1201
  const {
1101
1202
  file,
@@ -1110,15 +1211,17 @@ async function startP2PSend(opts) {
1110
1211
  cryptoObj,
1111
1212
  maxAttempts = 4,
1112
1213
  chunkSize = 256 * 1024,
1113
- readyTimeoutMs = 8e3,
1114
1214
  endAckTimeoutMs = 15e3,
1115
1215
  bufferHighWaterMark = 8 * 1024 * 1024,
1116
1216
  bufferLowWaterMark = 2 * 1024 * 1024,
1217
+ heartbeatIntervalMs = 5e3,
1117
1218
  onCode,
1118
1219
  onStatus,
1119
1220
  onProgress,
1120
1221
  onComplete,
1121
- onError
1222
+ onError,
1223
+ onDisconnect,
1224
+ onCancel
1122
1225
  } = opts;
1123
1226
  if (!file) {
1124
1227
  throw new DropgateValidationError("File is missing.");
@@ -1132,8 +1235,10 @@ async function startP2PSend(opts) {
1132
1235
  if (serverInfo && !p2pCaps?.enabled) {
1133
1236
  throw new DropgateValidationError("Direct transfer is disabled on this server.");
1134
1237
  }
1135
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1136
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1238
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1239
+ { peerjsPath, iceServers },
1240
+ p2pCaps
1241
+ );
1137
1242
  const peerOpts = buildPeerOptions({
1138
1243
  host,
1139
1244
  port,
@@ -1150,18 +1255,37 @@ async function startP2PSend(opts) {
1150
1255
  buildPeer,
1151
1256
  onCode
1152
1257
  });
1153
- let stopped = false;
1258
+ const sessionId = generateSessionId();
1259
+ let state = "listening";
1154
1260
  let activeConn = null;
1155
- let transferActive = false;
1156
- let transferCompleted = false;
1261
+ let sentBytes = 0;
1262
+ let heartbeatTimer = null;
1157
1263
  const reportProgress = (data) => {
1158
1264
  const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
1159
1265
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
1160
1266
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
1161
- onProgress?.({ sent: safeReceived, total: safeTotal, percent });
1267
+ onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1162
1268
  };
1163
- const stop = () => {
1164
- stopped = true;
1269
+ const safeError = (err) => {
1270
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1271
+ state = "closed";
1272
+ onError?.(err);
1273
+ cleanup();
1274
+ };
1275
+ const safeComplete = () => {
1276
+ if (state !== "finishing") return;
1277
+ state = "completed";
1278
+ onComplete?.();
1279
+ cleanup();
1280
+ };
1281
+ const cleanup = () => {
1282
+ if (heartbeatTimer) {
1283
+ clearInterval(heartbeatTimer);
1284
+ heartbeatTimer = null;
1285
+ }
1286
+ if (typeof window !== "undefined") {
1287
+ window.removeEventListener("beforeunload", handleUnload);
1288
+ }
1165
1289
  try {
1166
1290
  activeConn?.close();
1167
1291
  } catch {
@@ -1171,21 +1295,69 @@ async function startP2PSend(opts) {
1171
1295
  } catch {
1172
1296
  }
1173
1297
  };
1298
+ const handleUnload = () => {
1299
+ try {
1300
+ activeConn?.send({ t: "error", message: "Sender closed the connection." });
1301
+ } catch {
1302
+ }
1303
+ stop();
1304
+ };
1305
+ if (typeof window !== "undefined") {
1306
+ window.addEventListener("beforeunload", handleUnload);
1307
+ }
1308
+ const stop = () => {
1309
+ if (state === "closed" || state === "cancelled") return;
1310
+ const wasActive = state === "transferring" || state === "finishing";
1311
+ state = "cancelled";
1312
+ try {
1313
+ if (activeConn && activeConn.open) {
1314
+ activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
1315
+ }
1316
+ } catch {
1317
+ }
1318
+ if (wasActive && onCancel) {
1319
+ onCancel({ cancelledBy: "sender" });
1320
+ }
1321
+ cleanup();
1322
+ };
1323
+ const isStopped = () => state === "closed" || state === "cancelled";
1174
1324
  peer.on("connection", (conn) => {
1175
- if (stopped) return;
1325
+ if (state === "closed") return;
1176
1326
  if (activeConn) {
1177
- try {
1178
- conn.send({ t: "error", message: "Another receiver is already connected." });
1179
- } catch {
1180
- }
1181
- try {
1182
- conn.close();
1183
- } catch {
1327
+ const isOldConnOpen = activeConn.open !== false;
1328
+ if (isOldConnOpen && state === "transferring") {
1329
+ try {
1330
+ conn.send({ t: "error", message: "Transfer already in progress." });
1331
+ } catch {
1332
+ }
1333
+ try {
1334
+ conn.close();
1335
+ } catch {
1336
+ }
1337
+ return;
1338
+ } else if (!isOldConnOpen) {
1339
+ try {
1340
+ activeConn.close();
1341
+ } catch {
1342
+ }
1343
+ activeConn = null;
1344
+ state = "listening";
1345
+ sentBytes = 0;
1346
+ } else {
1347
+ try {
1348
+ conn.send({ t: "error", message: "Another receiver is already connected." });
1349
+ } catch {
1350
+ }
1351
+ try {
1352
+ conn.close();
1353
+ } catch {
1354
+ }
1355
+ return;
1184
1356
  }
1185
- return;
1186
1357
  }
1187
1358
  activeConn = conn;
1188
- onStatus?.({ phase: "connected", message: "Connected. Starting transfer..." });
1359
+ state = "negotiating";
1360
+ onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
1189
1361
  let readyResolve = null;
1190
1362
  let ackResolve = null;
1191
1363
  const readyPromise = new Promise((resolve) => {
@@ -1201,6 +1373,7 @@ async function startP2PSend(opts) {
1201
1373
  const msg = data;
1202
1374
  if (!msg.t) return;
1203
1375
  if (msg.t === "ready") {
1376
+ onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
1204
1377
  readyResolve?.();
1205
1378
  return;
1206
1379
  }
@@ -1212,22 +1385,30 @@ async function startP2PSend(opts) {
1212
1385
  ackResolve?.(msg);
1213
1386
  return;
1214
1387
  }
1388
+ if (msg.t === "pong") {
1389
+ return;
1390
+ }
1215
1391
  if (msg.t === "error") {
1216
- onError?.(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1217
- stop();
1392
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1393
+ return;
1394
+ }
1395
+ if (msg.t === "cancelled") {
1396
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1397
+ state = "cancelled";
1398
+ onCancel?.({ cancelledBy: "receiver", message: msg.message });
1399
+ cleanup();
1218
1400
  }
1219
1401
  });
1220
1402
  conn.on("open", async () => {
1221
1403
  try {
1222
- transferActive = true;
1223
- if (stopped) return;
1404
+ if (isStopped()) return;
1224
1405
  conn.send({
1225
1406
  t: "meta",
1407
+ sessionId,
1226
1408
  name: file.name,
1227
1409
  size: file.size,
1228
1410
  mime: file.type || "application/octet-stream"
1229
1411
  });
1230
- let sent = 0;
1231
1412
  const total = file.size;
1232
1413
  const dc = conn._dc;
1233
1414
  if (dc && Number.isFinite(bufferLowWaterMark)) {
@@ -1236,13 +1417,26 @@ async function startP2PSend(opts) {
1236
1417
  } catch {
1237
1418
  }
1238
1419
  }
1239
- await Promise.race([readyPromise, sleep(readyTimeoutMs).catch(() => null)]);
1420
+ await readyPromise;
1421
+ if (isStopped()) return;
1422
+ if (heartbeatIntervalMs > 0) {
1423
+ heartbeatTimer = setInterval(() => {
1424
+ if (state === "transferring" || state === "finishing") {
1425
+ try {
1426
+ conn.send({ t: "ping" });
1427
+ } catch {
1428
+ }
1429
+ }
1430
+ }, heartbeatIntervalMs);
1431
+ }
1432
+ state = "transferring";
1240
1433
  for (let offset = 0; offset < total; offset += chunkSize) {
1241
- if (stopped) return;
1434
+ if (isStopped()) return;
1242
1435
  const slice = file.slice(offset, offset + chunkSize);
1243
1436
  const buf = await slice.arrayBuffer();
1437
+ if (isStopped()) return;
1244
1438
  conn.send(buf);
1245
- sent += buf.byteLength;
1439
+ sentBytes += buf.byteLength;
1246
1440
  if (dc) {
1247
1441
  while (dc.bufferedAmount > bufferHighWaterMark) {
1248
1442
  await new Promise((resolve) => {
@@ -1262,13 +1456,15 @@ async function startP2PSend(opts) {
1262
1456
  }
1263
1457
  }
1264
1458
  }
1265
- if (stopped) return;
1459
+ if (isStopped()) return;
1460
+ state = "finishing";
1266
1461
  conn.send({ t: "end" });
1267
1462
  const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
1268
1463
  const ackResult = await Promise.race([
1269
1464
  ackPromise,
1270
1465
  sleep(ackTimeoutMs || 15e3).catch(() => null)
1271
1466
  ]);
1467
+ if (isStopped()) return;
1272
1468
  if (!ackResult || typeof ackResult !== "object") {
1273
1469
  throw new DropgateNetworkError("Receiver did not confirm completion.");
1274
1470
  }
@@ -1279,29 +1475,43 @@ async function startP2PSend(opts) {
1279
1475
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
1280
1476
  }
1281
1477
  reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
1282
- transferCompleted = true;
1283
- transferActive = false;
1284
- onComplete?.();
1285
- stop();
1478
+ safeComplete();
1286
1479
  } catch (err) {
1287
- onError?.(err);
1288
- stop();
1480
+ safeError(err);
1289
1481
  }
1290
1482
  });
1291
1483
  conn.on("error", (err) => {
1292
- onError?.(err);
1293
- stop();
1484
+ safeError(err);
1294
1485
  });
1295
1486
  conn.on("close", () => {
1296
- if (!transferCompleted && transferActive && !stopped) {
1297
- onError?.(
1298
- new DropgateNetworkError("Receiver disconnected before transfer completed.")
1299
- );
1487
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1488
+ cleanup();
1489
+ return;
1490
+ }
1491
+ if (state === "transferring" || state === "finishing") {
1492
+ state = "cancelled";
1493
+ onCancel?.({ cancelledBy: "receiver" });
1494
+ cleanup();
1495
+ } else {
1496
+ activeConn = null;
1497
+ state = "listening";
1498
+ sentBytes = 0;
1499
+ onDisconnect?.();
1300
1500
  }
1301
- stop();
1302
1501
  });
1303
1502
  });
1304
- return { peer, code, stop };
1503
+ return {
1504
+ peer,
1505
+ code,
1506
+ sessionId,
1507
+ stop,
1508
+ getStatus: () => state,
1509
+ getBytesSent: () => sentBytes,
1510
+ getConnectedPeerId: () => {
1511
+ if (!activeConn) return null;
1512
+ return activeConn.peer || null;
1513
+ }
1514
+ };
1305
1515
  }
1306
1516
 
1307
1517
  // src/p2p/receive.ts
@@ -1315,13 +1525,16 @@ async function startP2PReceive(opts) {
1315
1525
  peerjsPath,
1316
1526
  secure = false,
1317
1527
  iceServers,
1528
+ autoReady = true,
1529
+ watchdogTimeoutMs = 15e3,
1318
1530
  onStatus,
1319
1531
  onMeta,
1320
1532
  onData,
1321
1533
  onProgress,
1322
1534
  onComplete,
1323
1535
  onError,
1324
- onDisconnect
1536
+ onDisconnect,
1537
+ onCancel
1325
1538
  } = opts;
1326
1539
  if (!code) {
1327
1540
  throw new DropgateValidationError("No sharing code was provided.");
@@ -1339,8 +1552,10 @@ async function startP2PReceive(opts) {
1339
1552
  if (!isP2PCodeLike(normalizedCode)) {
1340
1553
  throw new DropgateValidationError("Invalid direct transfer code.");
1341
1554
  }
1342
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1343
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1555
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1556
+ { peerjsPath, iceServers },
1557
+ p2pCaps
1558
+ );
1344
1559
  const peerOpts = buildPeerOptions({
1345
1560
  host,
1346
1561
  port,
@@ -1349,44 +1564,137 @@ async function startP2PReceive(opts) {
1349
1564
  iceServers: finalIceServers
1350
1565
  });
1351
1566
  const peer = new Peer(void 0, peerOpts);
1567
+ let state = "initializing";
1352
1568
  let total = 0;
1353
1569
  let received = 0;
1570
+ let currentSessionId = null;
1354
1571
  let lastProgressSentAt = 0;
1355
1572
  const progressIntervalMs = 120;
1356
1573
  let writeQueue = Promise.resolve();
1357
- const stop = () => {
1574
+ let watchdogTimer = null;
1575
+ let activeConn = null;
1576
+ const resetWatchdog = () => {
1577
+ if (watchdogTimeoutMs <= 0) return;
1578
+ if (watchdogTimer) {
1579
+ clearTimeout(watchdogTimer);
1580
+ }
1581
+ watchdogTimer = setTimeout(() => {
1582
+ if (state === "transferring") {
1583
+ safeError(new DropgateNetworkError("Connection timed out (no data received)."));
1584
+ }
1585
+ }, watchdogTimeoutMs);
1586
+ };
1587
+ const clearWatchdog = () => {
1588
+ if (watchdogTimer) {
1589
+ clearTimeout(watchdogTimer);
1590
+ watchdogTimer = null;
1591
+ }
1592
+ };
1593
+ const safeError = (err) => {
1594
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1595
+ state = "closed";
1596
+ onError?.(err);
1597
+ cleanup();
1598
+ };
1599
+ const safeComplete = (completeData) => {
1600
+ if (state !== "transferring") return;
1601
+ state = "completed";
1602
+ onComplete?.(completeData);
1603
+ cleanup();
1604
+ };
1605
+ const cleanup = () => {
1606
+ clearWatchdog();
1607
+ if (typeof window !== "undefined") {
1608
+ window.removeEventListener("beforeunload", handleUnload);
1609
+ }
1358
1610
  try {
1359
1611
  peer.destroy();
1360
1612
  } catch {
1361
1613
  }
1362
1614
  };
1363
- peer.on("error", (err) => {
1364
- onError?.(err);
1615
+ const handleUnload = () => {
1616
+ try {
1617
+ activeConn?.send({ t: "error", message: "Receiver closed the connection." });
1618
+ } catch {
1619
+ }
1365
1620
  stop();
1621
+ };
1622
+ if (typeof window !== "undefined") {
1623
+ window.addEventListener("beforeunload", handleUnload);
1624
+ }
1625
+ const stop = () => {
1626
+ if (state === "closed" || state === "cancelled") return;
1627
+ const wasActive = state === "transferring";
1628
+ state = "cancelled";
1629
+ try {
1630
+ if (activeConn && activeConn.open) {
1631
+ activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
1632
+ }
1633
+ } catch {
1634
+ }
1635
+ if (wasActive && onCancel) {
1636
+ onCancel({ cancelledBy: "receiver" });
1637
+ }
1638
+ cleanup();
1639
+ };
1640
+ peer.on("error", (err) => {
1641
+ safeError(err);
1366
1642
  });
1367
1643
  peer.on("open", () => {
1644
+ state = "connecting";
1368
1645
  const conn = peer.connect(normalizedCode, { reliable: true });
1646
+ activeConn = conn;
1369
1647
  conn.on("open", () => {
1648
+ state = "negotiating";
1370
1649
  onStatus?.({ phase: "connected", message: "Waiting for file details..." });
1371
1650
  });
1372
1651
  conn.on("data", async (data) => {
1373
1652
  try {
1653
+ resetWatchdog();
1374
1654
  if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
1375
1655
  const msg = data;
1376
1656
  if (msg.t === "meta") {
1657
+ if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
1658
+ try {
1659
+ conn.send({ t: "error", message: "Busy with another session." });
1660
+ } catch {
1661
+ }
1662
+ return;
1663
+ }
1664
+ if (msg.sessionId) {
1665
+ currentSessionId = msg.sessionId;
1666
+ }
1377
1667
  const name = String(msg.name || "file");
1378
1668
  total = Number(msg.size) || 0;
1379
1669
  received = 0;
1380
1670
  writeQueue = Promise.resolve();
1381
- onMeta?.({ name, total });
1382
- onProgress?.({ received, total, percent: 0 });
1671
+ const sendReady = () => {
1672
+ state = "transferring";
1673
+ resetWatchdog();
1674
+ try {
1675
+ conn.send({ t: "ready" });
1676
+ } catch {
1677
+ }
1678
+ };
1679
+ if (autoReady) {
1680
+ onMeta?.({ name, total });
1681
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1682
+ sendReady();
1683
+ } else {
1684
+ onMeta?.({ name, total, sendReady });
1685
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1686
+ }
1687
+ return;
1688
+ }
1689
+ if (msg.t === "ping") {
1383
1690
  try {
1384
- conn.send({ t: "ready" });
1691
+ conn.send({ t: "pong" });
1385
1692
  } catch {
1386
1693
  }
1387
1694
  return;
1388
1695
  }
1389
1696
  if (msg.t === "end") {
1697
+ clearWatchdog();
1390
1698
  await writeQueue;
1391
1699
  if (total && received < total) {
1392
1700
  const err = new DropgateNetworkError(
@@ -1398,16 +1706,23 @@ async function startP2PReceive(opts) {
1398
1706
  }
1399
1707
  throw err;
1400
1708
  }
1401
- onComplete?.({ received, total });
1402
1709
  try {
1403
1710
  conn.send({ t: "ack", phase: "end", received, total });
1404
1711
  } catch {
1405
1712
  }
1713
+ safeComplete({ received, total });
1406
1714
  return;
1407
1715
  }
1408
1716
  if (msg.t === "error") {
1409
1717
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
1410
1718
  }
1719
+ if (msg.t === "cancelled") {
1720
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1721
+ state = "cancelled";
1722
+ onCancel?.({ cancelledBy: "sender", message: msg.message });
1723
+ cleanup();
1724
+ return;
1725
+ }
1411
1726
  return;
1412
1727
  }
1413
1728
  let bufPromise;
@@ -1429,7 +1744,7 @@ async function startP2PReceive(opts) {
1429
1744
  }
1430
1745
  received += buf.byteLength;
1431
1746
  const percent = total ? Math.min(100, received / total * 100) : 0;
1432
- onProgress?.({ received, total, percent });
1747
+ onProgress?.({ processedBytes: received, totalBytes: total, percent });
1433
1748
  const now = Date.now();
1434
1749
  if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
1435
1750
  lastProgressSentAt = now;
@@ -1446,21 +1761,38 @@ async function startP2PReceive(opts) {
1446
1761
  });
1447
1762
  } catch {
1448
1763
  }
1449
- onError?.(err);
1450
- stop();
1764
+ safeError(err);
1451
1765
  });
1452
1766
  } catch (err) {
1453
- onError?.(err);
1454
- stop();
1767
+ safeError(err);
1455
1768
  }
1456
1769
  });
1457
1770
  conn.on("close", () => {
1458
- if (received > 0 && total > 0 && received < total) {
1771
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1772
+ cleanup();
1773
+ return;
1774
+ }
1775
+ if (state === "transferring") {
1776
+ state = "cancelled";
1777
+ onCancel?.({ cancelledBy: "sender" });
1778
+ cleanup();
1779
+ } else if (state === "negotiating") {
1780
+ state = "closed";
1781
+ cleanup();
1459
1782
  onDisconnect?.();
1783
+ } else {
1784
+ safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
1460
1785
  }
1461
1786
  });
1462
1787
  });
1463
- return { peer, stop };
1788
+ return {
1789
+ peer,
1790
+ stop,
1791
+ getStatus: () => state,
1792
+ getBytesReceived: () => received,
1793
+ getTotalBytes: () => total,
1794
+ getSessionId: () => currentSessionId
1795
+ };
1464
1796
  }
1465
1797
  export {
1466
1798
  AES_GCM_IV_BYTES,
@@ -1492,6 +1824,7 @@ export {
1492
1824
  getDefaultBase64,
1493
1825
  getDefaultCrypto,
1494
1826
  getDefaultFetch,
1827
+ getServerInfo,
1495
1828
  importKeyFromBase64,
1496
1829
  isLocalhostHostname,
1497
1830
  isP2PCodeLike,
@@ -1500,6 +1833,7 @@ export {
1500
1833
  makeAbortSignal,
1501
1834
  parseSemverMajorMinor,
1502
1835
  parseServerUrl,
1836
+ resolvePeerConfig,
1503
1837
  sha256Hex,
1504
1838
  sleep,
1505
1839
  startP2PReceive,