@dropgate/core 2.0.0-beta.2 → 2.1.0

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
  /**
@@ -591,27 +615,17 @@ var DropgateClient = class {
591
615
  "Web Crypto API not available (crypto.subtle)."
592
616
  );
593
617
  }
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 });
618
+ const fileSizeBytes = file.size;
619
+ progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
620
+ const compat = await this.checkCompatibility({
621
+ host,
622
+ port,
623
+ secure,
624
+ timeoutMs: timeouts.serverInfoMs ?? 5e3,
625
+ signal
626
+ });
627
+ const { baseUrl, serverInfo } = compat;
628
+ progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
615
629
  if (!compat.compatible) {
616
630
  throw new DropgateValidationError(compat.message);
617
631
  }
@@ -624,7 +638,7 @@ var DropgateClient = class {
624
638
  let keyB64 = null;
625
639
  let transmittedFilename = filename;
626
640
  if (encrypt) {
627
- progress({ phase: "crypto", text: "Generating encryption key..." });
641
+ progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
628
642
  try {
629
643
  cryptoKey = await generateAesGcmKey(this.cryptoObj);
630
644
  keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
@@ -646,7 +660,7 @@ var DropgateClient = class {
646
660
  totalChunks,
647
661
  encrypt
648
662
  );
649
- progress({ phase: "init", text: "Reserving server storage..." });
663
+ progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
650
664
  const initPayload = {
651
665
  filename: transmittedFilename,
652
666
  lifetime: lifetimeMs,
@@ -689,10 +703,13 @@ var DropgateClient = class {
689
703
  const end = Math.min(start + this.chunkSize, file.size);
690
704
  let chunkBlob = file.slice(start, end);
691
705
  const percentComplete = i / totalChunks * 100;
706
+ const processedBytes = i * this.chunkSize;
692
707
  progress({
693
708
  phase: "chunk",
694
709
  text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
695
710
  percent: percentComplete,
711
+ processedBytes,
712
+ totalBytes: fileSizeBytes,
696
713
  chunkIndex: i,
697
714
  totalChunks
698
715
  });
@@ -732,11 +749,13 @@ var DropgateClient = class {
732
749
  signal,
733
750
  progress,
734
751
  chunkIndex: i,
735
- totalChunks
752
+ totalChunks,
753
+ chunkSize: this.chunkSize,
754
+ fileSizeBytes
736
755
  }
737
756
  );
738
757
  }
739
- progress({ phase: "complete", text: "Finalising upload...", percent: 100 });
758
+ progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
740
759
  const completeRes = await fetchJson(
741
760
  this.fetchFn,
742
761
  `${baseUrl}/upload/complete`,
@@ -769,7 +788,7 @@ var DropgateClient = class {
769
788
  if (encrypt && keyB64) {
770
789
  downloadUrl += `#${keyB64}`;
771
790
  }
772
- progress({ phase: "done", text: "Upload successful!", percent: 100 });
791
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
773
792
  return {
774
793
  downloadUrl,
775
794
  fileId,
@@ -815,8 +834,20 @@ var DropgateClient = class {
815
834
  if (!fileId || typeof fileId !== "string") {
816
835
  throw new DropgateValidationError("File ID is required.");
817
836
  }
818
- const baseUrl = buildBaseUrl({ host, port, secure });
819
- progress({ phase: "metadata", text: "Fetching file info...", receivedBytes: 0, totalBytes: 0, percent: 0 });
837
+ progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
838
+ const compat = await this.checkCompatibility({
839
+ host,
840
+ port,
841
+ secure,
842
+ timeoutMs,
843
+ signal
844
+ });
845
+ const { baseUrl } = compat;
846
+ progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
847
+ if (!compat.compatible) {
848
+ throw new DropgateValidationError(compat.message);
849
+ }
850
+ progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
820
851
  const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
821
852
  let metadata;
822
853
  try {
@@ -859,7 +890,7 @@ var DropgateClient = class {
859
890
  if (!this.cryptoObj?.subtle) {
860
891
  throw new DropgateValidationError("Web Crypto API not available for decryption.");
861
892
  }
862
- progress({ phase: "decrypting", text: "Preparing decryption...", receivedBytes: 0, totalBytes: 0, percent: 0 });
893
+ progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
863
894
  try {
864
895
  cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
865
896
  filename = await decryptFilenameFromBase64(
@@ -877,7 +908,7 @@ var DropgateClient = class {
877
908
  } else {
878
909
  filename = metadata.filename || "file";
879
910
  }
880
- progress({ phase: "downloading", text: "Starting download...", percent: 0, receivedBytes: 0, totalBytes });
911
+ progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
881
912
  const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
882
913
  let receivedBytes = 0;
883
914
  const dataChunks = [];
@@ -946,7 +977,7 @@ var DropgateClient = class {
946
977
  phase: "decrypting",
947
978
  text: `Downloading & decrypting... (${percent}%)`,
948
979
  percent,
949
- receivedBytes,
980
+ processedBytes: receivedBytes,
950
981
  totalBytes
951
982
  });
952
983
  }
@@ -978,7 +1009,7 @@ var DropgateClient = class {
978
1009
  phase: "downloading",
979
1010
  text: `Downloading... (${percent}%)`,
980
1011
  percent,
981
- receivedBytes,
1012
+ processedBytes: receivedBytes,
982
1013
  totalBytes
983
1014
  });
984
1015
  }
@@ -992,7 +1023,7 @@ var DropgateClient = class {
992
1023
  } finally {
993
1024
  downloadCleanup();
994
1025
  }
995
- progress({ phase: "complete", text: "Download complete!", percent: 100, receivedBytes, totalBytes });
1026
+ progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
996
1027
  let data;
997
1028
  if (collectData && dataChunks.length > 0) {
998
1029
  const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
@@ -1019,7 +1050,9 @@ var DropgateClient = class {
1019
1050
  signal,
1020
1051
  progress,
1021
1052
  chunkIndex,
1022
- totalChunks
1053
+ totalChunks,
1054
+ chunkSize,
1055
+ fileSizeBytes
1023
1056
  } = opts;
1024
1057
  let attemptsLeft = retries;
1025
1058
  let currentBackoff = backoffMs;
@@ -1052,6 +1085,8 @@ var DropgateClient = class {
1052
1085
  throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
1053
1086
  }
1054
1087
  const attemptNumber = maxRetries - attemptsLeft + 1;
1088
+ const processedBytes = chunkIndex * chunkSize;
1089
+ const percent = chunkIndex / totalChunks * 100;
1055
1090
  let remaining = currentBackoff;
1056
1091
  const tick = 100;
1057
1092
  while (remaining > 0) {
@@ -1059,6 +1094,9 @@ var DropgateClient = class {
1059
1094
  progress({
1060
1095
  phase: "retry-wait",
1061
1096
  text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
1097
+ percent,
1098
+ processedBytes,
1099
+ totalBytes: fileSizeBytes,
1062
1100
  chunkIndex,
1063
1101
  totalChunks
1064
1102
  });
@@ -1068,6 +1106,9 @@ var DropgateClient = class {
1068
1106
  progress({
1069
1107
  phase: "retry",
1070
1108
  text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
1109
+ percent,
1110
+ processedBytes,
1111
+ totalBytes: fileSizeBytes,
1071
1112
  chunkIndex,
1072
1113
  totalChunks
1073
1114
  });
@@ -1090,11 +1131,11 @@ function isSecureContextForP2P(hostname, isSecureContext) {
1090
1131
  return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
1091
1132
  }
1092
1133
  function generateP2PCode(cryptoObj) {
1093
- const crypto = cryptoObj || getDefaultCrypto();
1134
+ const crypto2 = cryptoObj || getDefaultCrypto();
1094
1135
  const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
1095
- if (crypto) {
1136
+ if (crypto2) {
1096
1137
  const randomBytes = new Uint8Array(8);
1097
- crypto.getRandomValues(randomBytes);
1138
+ crypto2.getRandomValues(randomBytes);
1098
1139
  let letterPart = "";
1099
1140
  for (let i = 0; i < 4; i++) {
1100
1141
  letterPart += letters[randomBytes[i] % letters.length];
@@ -1120,8 +1161,14 @@ function isP2PCodeLike(code) {
1120
1161
  }
1121
1162
 
1122
1163
  // src/p2p/helpers.ts
1123
- function buildPeerOptions(opts = {}) {
1124
- const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = opts;
1164
+ function resolvePeerConfig(userConfig, serverCaps) {
1165
+ return {
1166
+ path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
1167
+ iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
1168
+ };
1169
+ }
1170
+ function buildPeerOptions(config = {}) {
1171
+ const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
1125
1172
  const peerOpts = {
1126
1173
  host,
1127
1174
  path: peerjsPath,
@@ -1163,6 +1210,12 @@ async function createPeerWithRetries(opts) {
1163
1210
  }
1164
1211
 
1165
1212
  // src/p2p/send.ts
1213
+ function generateSessionId() {
1214
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
1215
+ return crypto.randomUUID();
1216
+ }
1217
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
1218
+ }
1166
1219
  async function startP2PSend(opts) {
1167
1220
  const {
1168
1221
  file,
@@ -1177,15 +1230,16 @@ async function startP2PSend(opts) {
1177
1230
  cryptoObj,
1178
1231
  maxAttempts = 4,
1179
1232
  chunkSize = 256 * 1024,
1180
- readyTimeoutMs = 8e3,
1181
1233
  endAckTimeoutMs = 15e3,
1182
1234
  bufferHighWaterMark = 8 * 1024 * 1024,
1183
1235
  bufferLowWaterMark = 2 * 1024 * 1024,
1236
+ heartbeatIntervalMs = 5e3,
1184
1237
  onCode,
1185
1238
  onStatus,
1186
1239
  onProgress,
1187
1240
  onComplete,
1188
- onError
1241
+ onError,
1242
+ onDisconnect
1189
1243
  } = opts;
1190
1244
  if (!file) {
1191
1245
  throw new DropgateValidationError("File is missing.");
@@ -1199,8 +1253,10 @@ async function startP2PSend(opts) {
1199
1253
  if (serverInfo && !p2pCaps?.enabled) {
1200
1254
  throw new DropgateValidationError("Direct transfer is disabled on this server.");
1201
1255
  }
1202
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1203
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1256
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1257
+ { peerjsPath, iceServers },
1258
+ p2pCaps
1259
+ );
1204
1260
  const peerOpts = buildPeerOptions({
1205
1261
  host,
1206
1262
  port,
@@ -1217,18 +1273,37 @@ async function startP2PSend(opts) {
1217
1273
  buildPeer,
1218
1274
  onCode
1219
1275
  });
1220
- let stopped = false;
1276
+ const sessionId = generateSessionId();
1277
+ let state = "listening";
1221
1278
  let activeConn = null;
1222
- let transferActive = false;
1223
- let transferCompleted = false;
1279
+ let sentBytes = 0;
1280
+ let heartbeatTimer = null;
1224
1281
  const reportProgress = (data) => {
1225
1282
  const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
1226
1283
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
1227
1284
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
1228
- onProgress?.({ sent: safeReceived, total: safeTotal, percent });
1285
+ onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1229
1286
  };
1230
- const stop = () => {
1231
- stopped = true;
1287
+ const safeError = (err) => {
1288
+ if (state === "closed" || state === "completed") return;
1289
+ state = "closed";
1290
+ onError?.(err);
1291
+ cleanup();
1292
+ };
1293
+ const safeComplete = () => {
1294
+ if (state !== "finishing") return;
1295
+ state = "completed";
1296
+ onComplete?.();
1297
+ cleanup();
1298
+ };
1299
+ const cleanup = () => {
1300
+ if (heartbeatTimer) {
1301
+ clearInterval(heartbeatTimer);
1302
+ heartbeatTimer = null;
1303
+ }
1304
+ if (typeof window !== "undefined") {
1305
+ window.removeEventListener("beforeunload", handleUnload);
1306
+ }
1232
1307
  try {
1233
1308
  activeConn?.close();
1234
1309
  } catch {
@@ -1238,21 +1313,59 @@ async function startP2PSend(opts) {
1238
1313
  } catch {
1239
1314
  }
1240
1315
  };
1316
+ const handleUnload = () => {
1317
+ try {
1318
+ activeConn?.send({ t: "error", message: "Sender closed the connection." });
1319
+ } catch {
1320
+ }
1321
+ stop();
1322
+ };
1323
+ if (typeof window !== "undefined") {
1324
+ window.addEventListener("beforeunload", handleUnload);
1325
+ }
1326
+ const stop = () => {
1327
+ if (state === "closed") return;
1328
+ state = "closed";
1329
+ cleanup();
1330
+ };
1331
+ const isStopped = () => state === "closed";
1241
1332
  peer.on("connection", (conn) => {
1242
- if (stopped) return;
1333
+ if (state === "closed") return;
1243
1334
  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 {
1335
+ const isOldConnOpen = activeConn.open !== false;
1336
+ if (isOldConnOpen && state === "transferring") {
1337
+ try {
1338
+ conn.send({ t: "error", message: "Transfer already in progress." });
1339
+ } catch {
1340
+ }
1341
+ try {
1342
+ conn.close();
1343
+ } catch {
1344
+ }
1345
+ return;
1346
+ } else if (!isOldConnOpen) {
1347
+ try {
1348
+ activeConn.close();
1349
+ } catch {
1350
+ }
1351
+ activeConn = null;
1352
+ state = "listening";
1353
+ sentBytes = 0;
1354
+ } else {
1355
+ try {
1356
+ conn.send({ t: "error", message: "Another receiver is already connected." });
1357
+ } catch {
1358
+ }
1359
+ try {
1360
+ conn.close();
1361
+ } catch {
1362
+ }
1363
+ return;
1251
1364
  }
1252
- return;
1253
1365
  }
1254
1366
  activeConn = conn;
1255
- onStatus?.({ phase: "connected", message: "Connected. Starting transfer..." });
1367
+ state = "negotiating";
1368
+ onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
1256
1369
  let readyResolve = null;
1257
1370
  let ackResolve = null;
1258
1371
  const readyPromise = new Promise((resolve) => {
@@ -1268,6 +1381,7 @@ async function startP2PSend(opts) {
1268
1381
  const msg = data;
1269
1382
  if (!msg.t) return;
1270
1383
  if (msg.t === "ready") {
1384
+ onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
1271
1385
  readyResolve?.();
1272
1386
  return;
1273
1387
  }
@@ -1279,22 +1393,23 @@ async function startP2PSend(opts) {
1279
1393
  ackResolve?.(msg);
1280
1394
  return;
1281
1395
  }
1396
+ if (msg.t === "pong") {
1397
+ return;
1398
+ }
1282
1399
  if (msg.t === "error") {
1283
- onError?.(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1284
- stop();
1400
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1285
1401
  }
1286
1402
  });
1287
1403
  conn.on("open", async () => {
1288
1404
  try {
1289
- transferActive = true;
1290
- if (stopped) return;
1405
+ if (isStopped()) return;
1291
1406
  conn.send({
1292
1407
  t: "meta",
1408
+ sessionId,
1293
1409
  name: file.name,
1294
1410
  size: file.size,
1295
1411
  mime: file.type || "application/octet-stream"
1296
1412
  });
1297
- let sent = 0;
1298
1413
  const total = file.size;
1299
1414
  const dc = conn._dc;
1300
1415
  if (dc && Number.isFinite(bufferLowWaterMark)) {
@@ -1303,13 +1418,25 @@ async function startP2PSend(opts) {
1303
1418
  } catch {
1304
1419
  }
1305
1420
  }
1306
- await Promise.race([readyPromise, sleep(readyTimeoutMs).catch(() => null)]);
1421
+ await readyPromise;
1422
+ if (isStopped()) return;
1423
+ if (heartbeatIntervalMs > 0) {
1424
+ heartbeatTimer = setInterval(() => {
1425
+ if (state === "transferring" || state === "finishing") {
1426
+ try {
1427
+ conn.send({ t: "ping" });
1428
+ } catch {
1429
+ }
1430
+ }
1431
+ }, heartbeatIntervalMs);
1432
+ }
1433
+ state = "transferring";
1307
1434
  for (let offset = 0; offset < total; offset += chunkSize) {
1308
- if (stopped) return;
1435
+ if (isStopped()) return;
1309
1436
  const slice = file.slice(offset, offset + chunkSize);
1310
1437
  const buf = await slice.arrayBuffer();
1311
1438
  conn.send(buf);
1312
- sent += buf.byteLength;
1439
+ sentBytes += buf.byteLength;
1313
1440
  if (dc) {
1314
1441
  while (dc.bufferedAmount > bufferHighWaterMark) {
1315
1442
  await new Promise((resolve) => {
@@ -1329,13 +1456,15 @@ async function startP2PSend(opts) {
1329
1456
  }
1330
1457
  }
1331
1458
  }
1332
- if (stopped) return;
1459
+ if (isStopped()) return;
1460
+ state = "finishing";
1333
1461
  conn.send({ t: "end" });
1334
1462
  const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
1335
1463
  const ackResult = await Promise.race([
1336
1464
  ackPromise,
1337
1465
  sleep(ackTimeoutMs || 15e3).catch(() => null)
1338
1466
  ]);
1467
+ if (isStopped()) return;
1339
1468
  if (!ackResult || typeof ackResult !== "object") {
1340
1469
  throw new DropgateNetworkError("Receiver did not confirm completion.");
1341
1470
  }
@@ -1346,29 +1475,43 @@ async function startP2PSend(opts) {
1346
1475
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
1347
1476
  }
1348
1477
  reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
1349
- transferCompleted = true;
1350
- transferActive = false;
1351
- onComplete?.();
1352
- stop();
1478
+ safeComplete();
1353
1479
  } catch (err) {
1354
- onError?.(err);
1355
- stop();
1480
+ safeError(err);
1356
1481
  }
1357
1482
  });
1358
1483
  conn.on("error", (err) => {
1359
- onError?.(err);
1360
- stop();
1484
+ safeError(err);
1361
1485
  });
1362
1486
  conn.on("close", () => {
1363
- if (!transferCompleted && transferActive && !stopped) {
1364
- onError?.(
1487
+ if (state === "closed" || state === "completed") {
1488
+ cleanup();
1489
+ return;
1490
+ }
1491
+ if (state === "transferring" || state === "finishing") {
1492
+ safeError(
1365
1493
  new DropgateNetworkError("Receiver disconnected before transfer completed.")
1366
1494
  );
1495
+ } else {
1496
+ activeConn = null;
1497
+ state = "listening";
1498
+ sentBytes = 0;
1499
+ onDisconnect?.();
1367
1500
  }
1368
- stop();
1369
1501
  });
1370
1502
  });
1371
- 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
+ };
1372
1515
  }
1373
1516
 
1374
1517
  // src/p2p/receive.ts
@@ -1382,6 +1525,8 @@ async function startP2PReceive(opts) {
1382
1525
  peerjsPath,
1383
1526
  secure = false,
1384
1527
  iceServers,
1528
+ autoReady = true,
1529
+ watchdogTimeoutMs = 15e3,
1385
1530
  onStatus,
1386
1531
  onMeta,
1387
1532
  onData,
@@ -1406,8 +1551,10 @@ async function startP2PReceive(opts) {
1406
1551
  if (!isP2PCodeLike(normalizedCode)) {
1407
1552
  throw new DropgateValidationError("Invalid direct transfer code.");
1408
1553
  }
1409
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1410
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1554
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1555
+ { peerjsPath, iceServers },
1556
+ p2pCaps
1557
+ );
1411
1558
  const peerOpts = buildPeerOptions({
1412
1559
  host,
1413
1560
  port,
@@ -1416,44 +1563,127 @@ async function startP2PReceive(opts) {
1416
1563
  iceServers: finalIceServers
1417
1564
  });
1418
1565
  const peer = new Peer(void 0, peerOpts);
1566
+ let state = "initializing";
1419
1567
  let total = 0;
1420
1568
  let received = 0;
1569
+ let currentSessionId = null;
1421
1570
  let lastProgressSentAt = 0;
1422
1571
  const progressIntervalMs = 120;
1423
1572
  let writeQueue = Promise.resolve();
1424
- const stop = () => {
1573
+ let watchdogTimer = null;
1574
+ let activeConn = null;
1575
+ const resetWatchdog = () => {
1576
+ if (watchdogTimeoutMs <= 0) return;
1577
+ if (watchdogTimer) {
1578
+ clearTimeout(watchdogTimer);
1579
+ }
1580
+ watchdogTimer = setTimeout(() => {
1581
+ if (state === "transferring") {
1582
+ safeError(new DropgateNetworkError("Connection timed out (no data received)."));
1583
+ }
1584
+ }, watchdogTimeoutMs);
1585
+ };
1586
+ const clearWatchdog = () => {
1587
+ if (watchdogTimer) {
1588
+ clearTimeout(watchdogTimer);
1589
+ watchdogTimer = null;
1590
+ }
1591
+ };
1592
+ const safeError = (err) => {
1593
+ if (state === "closed" || state === "completed") return;
1594
+ state = "closed";
1595
+ onError?.(err);
1596
+ cleanup();
1597
+ };
1598
+ const safeComplete = (completeData) => {
1599
+ if (state !== "transferring") return;
1600
+ state = "completed";
1601
+ onComplete?.(completeData);
1602
+ cleanup();
1603
+ };
1604
+ const cleanup = () => {
1605
+ clearWatchdog();
1606
+ if (typeof window !== "undefined") {
1607
+ window.removeEventListener("beforeunload", handleUnload);
1608
+ }
1425
1609
  try {
1426
1610
  peer.destroy();
1427
1611
  } catch {
1428
1612
  }
1429
1613
  };
1430
- peer.on("error", (err) => {
1431
- onError?.(err);
1614
+ const handleUnload = () => {
1615
+ try {
1616
+ activeConn?.send({ t: "error", message: "Receiver closed the connection." });
1617
+ } catch {
1618
+ }
1432
1619
  stop();
1620
+ };
1621
+ if (typeof window !== "undefined") {
1622
+ window.addEventListener("beforeunload", handleUnload);
1623
+ }
1624
+ const stop = () => {
1625
+ if (state === "closed") return;
1626
+ state = "closed";
1627
+ cleanup();
1628
+ };
1629
+ peer.on("error", (err) => {
1630
+ safeError(err);
1433
1631
  });
1434
1632
  peer.on("open", () => {
1633
+ state = "connecting";
1435
1634
  const conn = peer.connect(normalizedCode, { reliable: true });
1635
+ activeConn = conn;
1436
1636
  conn.on("open", () => {
1637
+ state = "negotiating";
1437
1638
  onStatus?.({ phase: "connected", message: "Waiting for file details..." });
1438
1639
  });
1439
1640
  conn.on("data", async (data) => {
1440
1641
  try {
1642
+ resetWatchdog();
1441
1643
  if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
1442
1644
  const msg = data;
1443
1645
  if (msg.t === "meta") {
1646
+ if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
1647
+ try {
1648
+ conn.send({ t: "error", message: "Busy with another session." });
1649
+ } catch {
1650
+ }
1651
+ return;
1652
+ }
1653
+ if (msg.sessionId) {
1654
+ currentSessionId = msg.sessionId;
1655
+ }
1444
1656
  const name = String(msg.name || "file");
1445
1657
  total = Number(msg.size) || 0;
1446
1658
  received = 0;
1447
1659
  writeQueue = Promise.resolve();
1448
- onMeta?.({ name, total });
1449
- onProgress?.({ received, total, percent: 0 });
1660
+ const sendReady = () => {
1661
+ state = "transferring";
1662
+ resetWatchdog();
1663
+ try {
1664
+ conn.send({ t: "ready" });
1665
+ } catch {
1666
+ }
1667
+ };
1668
+ if (autoReady) {
1669
+ onMeta?.({ name, total });
1670
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1671
+ sendReady();
1672
+ } else {
1673
+ onMeta?.({ name, total, sendReady });
1674
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1675
+ }
1676
+ return;
1677
+ }
1678
+ if (msg.t === "ping") {
1450
1679
  try {
1451
- conn.send({ t: "ready" });
1680
+ conn.send({ t: "pong" });
1452
1681
  } catch {
1453
1682
  }
1454
1683
  return;
1455
1684
  }
1456
1685
  if (msg.t === "end") {
1686
+ clearWatchdog();
1457
1687
  await writeQueue;
1458
1688
  if (total && received < total) {
1459
1689
  const err = new DropgateNetworkError(
@@ -1465,11 +1695,11 @@ async function startP2PReceive(opts) {
1465
1695
  }
1466
1696
  throw err;
1467
1697
  }
1468
- onComplete?.({ received, total });
1469
1698
  try {
1470
1699
  conn.send({ t: "ack", phase: "end", received, total });
1471
1700
  } catch {
1472
1701
  }
1702
+ safeComplete({ received, total });
1473
1703
  return;
1474
1704
  }
1475
1705
  if (msg.t === "error") {
@@ -1496,7 +1726,7 @@ async function startP2PReceive(opts) {
1496
1726
  }
1497
1727
  received += buf.byteLength;
1498
1728
  const percent = total ? Math.min(100, received / total * 100) : 0;
1499
- onProgress?.({ received, total, percent });
1729
+ onProgress?.({ processedBytes: received, totalBytes: total, percent });
1500
1730
  const now = Date.now();
1501
1731
  if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
1502
1732
  lastProgressSentAt = now;
@@ -1513,21 +1743,36 @@ async function startP2PReceive(opts) {
1513
1743
  });
1514
1744
  } catch {
1515
1745
  }
1516
- onError?.(err);
1517
- stop();
1746
+ safeError(err);
1518
1747
  });
1519
1748
  } catch (err) {
1520
- onError?.(err);
1521
- stop();
1749
+ safeError(err);
1522
1750
  }
1523
1751
  });
1524
1752
  conn.on("close", () => {
1525
- if (received > 0 && total > 0 && received < total) {
1753
+ if (state === "closed" || state === "completed") {
1754
+ cleanup();
1755
+ return;
1756
+ }
1757
+ if (state === "transferring") {
1758
+ safeError(new DropgateNetworkError("Sender disconnected during transfer."));
1759
+ } else if (state === "negotiating") {
1760
+ state = "closed";
1761
+ cleanup();
1526
1762
  onDisconnect?.();
1763
+ } else {
1764
+ safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
1527
1765
  }
1528
1766
  });
1529
1767
  });
1530
- return { peer, stop };
1768
+ return {
1769
+ peer,
1770
+ stop,
1771
+ getStatus: () => state,
1772
+ getBytesReceived: () => received,
1773
+ getTotalBytes: () => total,
1774
+ getSessionId: () => currentSessionId
1775
+ };
1531
1776
  }
1532
1777
  // Annotate the CommonJS export names for ESM import in node:
1533
1778
  0 && (module.exports = {
@@ -1560,6 +1805,7 @@ async function startP2PReceive(opts) {
1560
1805
  getDefaultBase64,
1561
1806
  getDefaultCrypto,
1562
1807
  getDefaultFetch,
1808
+ getServerInfo,
1563
1809
  importKeyFromBase64,
1564
1810
  isLocalhostHostname,
1565
1811
  isP2PCodeLike,
@@ -1568,6 +1814,7 @@ async function startP2PReceive(opts) {
1568
1814
  makeAbortSignal,
1569
1815
  parseSemverMajorMinor,
1570
1816
  parseServerUrl,
1817
+ resolvePeerConfig,
1571
1818
  sha256Hex,
1572
1819
  sleep,
1573
1820
  startP2PReceive,