@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.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
  /**
@@ -524,27 +546,17 @@ var DropgateClient = class {
524
546
  "Web Crypto API not available (crypto.subtle)."
525
547
  );
526
548
  }
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 });
549
+ const fileSizeBytes = file.size;
550
+ progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
551
+ const compat = await this.checkCompatibility({
552
+ host,
553
+ port,
554
+ secure,
555
+ timeoutMs: timeouts.serverInfoMs ?? 5e3,
556
+ signal
557
+ });
558
+ const { baseUrl, serverInfo } = compat;
559
+ progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
548
560
  if (!compat.compatible) {
549
561
  throw new DropgateValidationError(compat.message);
550
562
  }
@@ -557,7 +569,7 @@ var DropgateClient = class {
557
569
  let keyB64 = null;
558
570
  let transmittedFilename = filename;
559
571
  if (encrypt) {
560
- progress({ phase: "crypto", text: "Generating encryption key..." });
572
+ progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
561
573
  try {
562
574
  cryptoKey = await generateAesGcmKey(this.cryptoObj);
563
575
  keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
@@ -579,7 +591,7 @@ var DropgateClient = class {
579
591
  totalChunks,
580
592
  encrypt
581
593
  );
582
- progress({ phase: "init", text: "Reserving server storage..." });
594
+ progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
583
595
  const initPayload = {
584
596
  filename: transmittedFilename,
585
597
  lifetime: lifetimeMs,
@@ -622,10 +634,13 @@ var DropgateClient = class {
622
634
  const end = Math.min(start + this.chunkSize, file.size);
623
635
  let chunkBlob = file.slice(start, end);
624
636
  const percentComplete = i / totalChunks * 100;
637
+ const processedBytes = i * this.chunkSize;
625
638
  progress({
626
639
  phase: "chunk",
627
640
  text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
628
641
  percent: percentComplete,
642
+ processedBytes,
643
+ totalBytes: fileSizeBytes,
629
644
  chunkIndex: i,
630
645
  totalChunks
631
646
  });
@@ -665,11 +680,13 @@ var DropgateClient = class {
665
680
  signal,
666
681
  progress,
667
682
  chunkIndex: i,
668
- totalChunks
683
+ totalChunks,
684
+ chunkSize: this.chunkSize,
685
+ fileSizeBytes
669
686
  }
670
687
  );
671
688
  }
672
- progress({ phase: "complete", text: "Finalising upload...", percent: 100 });
689
+ progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
673
690
  const completeRes = await fetchJson(
674
691
  this.fetchFn,
675
692
  `${baseUrl}/upload/complete`,
@@ -702,7 +719,7 @@ var DropgateClient = class {
702
719
  if (encrypt && keyB64) {
703
720
  downloadUrl += `#${keyB64}`;
704
721
  }
705
- progress({ phase: "done", text: "Upload successful!", percent: 100 });
722
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
706
723
  return {
707
724
  downloadUrl,
708
725
  fileId,
@@ -748,8 +765,20 @@ var DropgateClient = class {
748
765
  if (!fileId || typeof fileId !== "string") {
749
766
  throw new DropgateValidationError("File ID is required.");
750
767
  }
751
- const baseUrl = buildBaseUrl({ host, port, secure });
752
- progress({ phase: "metadata", text: "Fetching file info...", receivedBytes: 0, totalBytes: 0, percent: 0 });
768
+ progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
769
+ const compat = await this.checkCompatibility({
770
+ host,
771
+ port,
772
+ secure,
773
+ timeoutMs,
774
+ signal
775
+ });
776
+ const { baseUrl } = compat;
777
+ progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
778
+ if (!compat.compatible) {
779
+ throw new DropgateValidationError(compat.message);
780
+ }
781
+ progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
753
782
  const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
754
783
  let metadata;
755
784
  try {
@@ -792,7 +821,7 @@ var DropgateClient = class {
792
821
  if (!this.cryptoObj?.subtle) {
793
822
  throw new DropgateValidationError("Web Crypto API not available for decryption.");
794
823
  }
795
- progress({ phase: "decrypting", text: "Preparing decryption...", receivedBytes: 0, totalBytes: 0, percent: 0 });
824
+ progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
796
825
  try {
797
826
  cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
798
827
  filename = await decryptFilenameFromBase64(
@@ -810,7 +839,7 @@ var DropgateClient = class {
810
839
  } else {
811
840
  filename = metadata.filename || "file";
812
841
  }
813
- progress({ phase: "downloading", text: "Starting download...", percent: 0, receivedBytes: 0, totalBytes });
842
+ progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
814
843
  const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
815
844
  let receivedBytes = 0;
816
845
  const dataChunks = [];
@@ -879,7 +908,7 @@ var DropgateClient = class {
879
908
  phase: "decrypting",
880
909
  text: `Downloading & decrypting... (${percent}%)`,
881
910
  percent,
882
- receivedBytes,
911
+ processedBytes: receivedBytes,
883
912
  totalBytes
884
913
  });
885
914
  }
@@ -911,7 +940,7 @@ var DropgateClient = class {
911
940
  phase: "downloading",
912
941
  text: `Downloading... (${percent}%)`,
913
942
  percent,
914
- receivedBytes,
943
+ processedBytes: receivedBytes,
915
944
  totalBytes
916
945
  });
917
946
  }
@@ -925,7 +954,7 @@ var DropgateClient = class {
925
954
  } finally {
926
955
  downloadCleanup();
927
956
  }
928
- progress({ phase: "complete", text: "Download complete!", percent: 100, receivedBytes, totalBytes });
957
+ progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
929
958
  let data;
930
959
  if (collectData && dataChunks.length > 0) {
931
960
  const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
@@ -952,7 +981,9 @@ var DropgateClient = class {
952
981
  signal,
953
982
  progress,
954
983
  chunkIndex,
955
- totalChunks
984
+ totalChunks,
985
+ chunkSize,
986
+ fileSizeBytes
956
987
  } = opts;
957
988
  let attemptsLeft = retries;
958
989
  let currentBackoff = backoffMs;
@@ -985,6 +1016,8 @@ var DropgateClient = class {
985
1016
  throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
986
1017
  }
987
1018
  const attemptNumber = maxRetries - attemptsLeft + 1;
1019
+ const processedBytes = chunkIndex * chunkSize;
1020
+ const percent = chunkIndex / totalChunks * 100;
988
1021
  let remaining = currentBackoff;
989
1022
  const tick = 100;
990
1023
  while (remaining > 0) {
@@ -992,6 +1025,9 @@ var DropgateClient = class {
992
1025
  progress({
993
1026
  phase: "retry-wait",
994
1027
  text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
1028
+ percent,
1029
+ processedBytes,
1030
+ totalBytes: fileSizeBytes,
995
1031
  chunkIndex,
996
1032
  totalChunks
997
1033
  });
@@ -1001,6 +1037,9 @@ var DropgateClient = class {
1001
1037
  progress({
1002
1038
  phase: "retry",
1003
1039
  text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
1040
+ percent,
1041
+ processedBytes,
1042
+ totalBytes: fileSizeBytes,
1004
1043
  chunkIndex,
1005
1044
  totalChunks
1006
1045
  });
@@ -1023,11 +1062,11 @@ function isSecureContextForP2P(hostname, isSecureContext) {
1023
1062
  return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
1024
1063
  }
1025
1064
  function generateP2PCode(cryptoObj) {
1026
- const crypto = cryptoObj || getDefaultCrypto();
1065
+ const crypto2 = cryptoObj || getDefaultCrypto();
1027
1066
  const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
1028
- if (crypto) {
1067
+ if (crypto2) {
1029
1068
  const randomBytes = new Uint8Array(8);
1030
- crypto.getRandomValues(randomBytes);
1069
+ crypto2.getRandomValues(randomBytes);
1031
1070
  let letterPart = "";
1032
1071
  for (let i = 0; i < 4; i++) {
1033
1072
  letterPart += letters[randomBytes[i] % letters.length];
@@ -1053,8 +1092,14 @@ function isP2PCodeLike(code) {
1053
1092
  }
1054
1093
 
1055
1094
  // src/p2p/helpers.ts
1056
- function buildPeerOptions(opts = {}) {
1057
- const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = opts;
1095
+ function resolvePeerConfig(userConfig, serverCaps) {
1096
+ return {
1097
+ path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
1098
+ iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
1099
+ };
1100
+ }
1101
+ function buildPeerOptions(config = {}) {
1102
+ const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
1058
1103
  const peerOpts = {
1059
1104
  host,
1060
1105
  path: peerjsPath,
@@ -1096,6 +1141,12 @@ async function createPeerWithRetries(opts) {
1096
1141
  }
1097
1142
 
1098
1143
  // src/p2p/send.ts
1144
+ function generateSessionId() {
1145
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
1146
+ return crypto.randomUUID();
1147
+ }
1148
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
1149
+ }
1099
1150
  async function startP2PSend(opts) {
1100
1151
  const {
1101
1152
  file,
@@ -1110,15 +1161,16 @@ async function startP2PSend(opts) {
1110
1161
  cryptoObj,
1111
1162
  maxAttempts = 4,
1112
1163
  chunkSize = 256 * 1024,
1113
- readyTimeoutMs = 8e3,
1114
1164
  endAckTimeoutMs = 15e3,
1115
1165
  bufferHighWaterMark = 8 * 1024 * 1024,
1116
1166
  bufferLowWaterMark = 2 * 1024 * 1024,
1167
+ heartbeatIntervalMs = 5e3,
1117
1168
  onCode,
1118
1169
  onStatus,
1119
1170
  onProgress,
1120
1171
  onComplete,
1121
- onError
1172
+ onError,
1173
+ onDisconnect
1122
1174
  } = opts;
1123
1175
  if (!file) {
1124
1176
  throw new DropgateValidationError("File is missing.");
@@ -1132,8 +1184,10 @@ async function startP2PSend(opts) {
1132
1184
  if (serverInfo && !p2pCaps?.enabled) {
1133
1185
  throw new DropgateValidationError("Direct transfer is disabled on this server.");
1134
1186
  }
1135
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1136
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1187
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1188
+ { peerjsPath, iceServers },
1189
+ p2pCaps
1190
+ );
1137
1191
  const peerOpts = buildPeerOptions({
1138
1192
  host,
1139
1193
  port,
@@ -1150,18 +1204,37 @@ async function startP2PSend(opts) {
1150
1204
  buildPeer,
1151
1205
  onCode
1152
1206
  });
1153
- let stopped = false;
1207
+ const sessionId = generateSessionId();
1208
+ let state = "listening";
1154
1209
  let activeConn = null;
1155
- let transferActive = false;
1156
- let transferCompleted = false;
1210
+ let sentBytes = 0;
1211
+ let heartbeatTimer = null;
1157
1212
  const reportProgress = (data) => {
1158
1213
  const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
1159
1214
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
1160
1215
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
1161
- onProgress?.({ sent: safeReceived, total: safeTotal, percent });
1216
+ onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1162
1217
  };
1163
- const stop = () => {
1164
- stopped = true;
1218
+ const safeError = (err) => {
1219
+ if (state === "closed" || state === "completed") return;
1220
+ state = "closed";
1221
+ onError?.(err);
1222
+ cleanup();
1223
+ };
1224
+ const safeComplete = () => {
1225
+ if (state !== "finishing") return;
1226
+ state = "completed";
1227
+ onComplete?.();
1228
+ cleanup();
1229
+ };
1230
+ const cleanup = () => {
1231
+ if (heartbeatTimer) {
1232
+ clearInterval(heartbeatTimer);
1233
+ heartbeatTimer = null;
1234
+ }
1235
+ if (typeof window !== "undefined") {
1236
+ window.removeEventListener("beforeunload", handleUnload);
1237
+ }
1165
1238
  try {
1166
1239
  activeConn?.close();
1167
1240
  } catch {
@@ -1171,21 +1244,59 @@ async function startP2PSend(opts) {
1171
1244
  } catch {
1172
1245
  }
1173
1246
  };
1247
+ const handleUnload = () => {
1248
+ try {
1249
+ activeConn?.send({ t: "error", message: "Sender closed the connection." });
1250
+ } catch {
1251
+ }
1252
+ stop();
1253
+ };
1254
+ if (typeof window !== "undefined") {
1255
+ window.addEventListener("beforeunload", handleUnload);
1256
+ }
1257
+ const stop = () => {
1258
+ if (state === "closed") return;
1259
+ state = "closed";
1260
+ cleanup();
1261
+ };
1262
+ const isStopped = () => state === "closed";
1174
1263
  peer.on("connection", (conn) => {
1175
- if (stopped) return;
1264
+ if (state === "closed") return;
1176
1265
  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 {
1266
+ const isOldConnOpen = activeConn.open !== false;
1267
+ if (isOldConnOpen && state === "transferring") {
1268
+ try {
1269
+ conn.send({ t: "error", message: "Transfer already in progress." });
1270
+ } catch {
1271
+ }
1272
+ try {
1273
+ conn.close();
1274
+ } catch {
1275
+ }
1276
+ return;
1277
+ } else if (!isOldConnOpen) {
1278
+ try {
1279
+ activeConn.close();
1280
+ } catch {
1281
+ }
1282
+ activeConn = null;
1283
+ state = "listening";
1284
+ sentBytes = 0;
1285
+ } else {
1286
+ try {
1287
+ conn.send({ t: "error", message: "Another receiver is already connected." });
1288
+ } catch {
1289
+ }
1290
+ try {
1291
+ conn.close();
1292
+ } catch {
1293
+ }
1294
+ return;
1184
1295
  }
1185
- return;
1186
1296
  }
1187
1297
  activeConn = conn;
1188
- onStatus?.({ phase: "connected", message: "Connected. Starting transfer..." });
1298
+ state = "negotiating";
1299
+ onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
1189
1300
  let readyResolve = null;
1190
1301
  let ackResolve = null;
1191
1302
  const readyPromise = new Promise((resolve) => {
@@ -1201,6 +1312,7 @@ async function startP2PSend(opts) {
1201
1312
  const msg = data;
1202
1313
  if (!msg.t) return;
1203
1314
  if (msg.t === "ready") {
1315
+ onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
1204
1316
  readyResolve?.();
1205
1317
  return;
1206
1318
  }
@@ -1212,22 +1324,23 @@ async function startP2PSend(opts) {
1212
1324
  ackResolve?.(msg);
1213
1325
  return;
1214
1326
  }
1327
+ if (msg.t === "pong") {
1328
+ return;
1329
+ }
1215
1330
  if (msg.t === "error") {
1216
- onError?.(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1217
- stop();
1331
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1218
1332
  }
1219
1333
  });
1220
1334
  conn.on("open", async () => {
1221
1335
  try {
1222
- transferActive = true;
1223
- if (stopped) return;
1336
+ if (isStopped()) return;
1224
1337
  conn.send({
1225
1338
  t: "meta",
1339
+ sessionId,
1226
1340
  name: file.name,
1227
1341
  size: file.size,
1228
1342
  mime: file.type || "application/octet-stream"
1229
1343
  });
1230
- let sent = 0;
1231
1344
  const total = file.size;
1232
1345
  const dc = conn._dc;
1233
1346
  if (dc && Number.isFinite(bufferLowWaterMark)) {
@@ -1236,13 +1349,25 @@ async function startP2PSend(opts) {
1236
1349
  } catch {
1237
1350
  }
1238
1351
  }
1239
- await Promise.race([readyPromise, sleep(readyTimeoutMs).catch(() => null)]);
1352
+ await readyPromise;
1353
+ if (isStopped()) return;
1354
+ if (heartbeatIntervalMs > 0) {
1355
+ heartbeatTimer = setInterval(() => {
1356
+ if (state === "transferring" || state === "finishing") {
1357
+ try {
1358
+ conn.send({ t: "ping" });
1359
+ } catch {
1360
+ }
1361
+ }
1362
+ }, heartbeatIntervalMs);
1363
+ }
1364
+ state = "transferring";
1240
1365
  for (let offset = 0; offset < total; offset += chunkSize) {
1241
- if (stopped) return;
1366
+ if (isStopped()) return;
1242
1367
  const slice = file.slice(offset, offset + chunkSize);
1243
1368
  const buf = await slice.arrayBuffer();
1244
1369
  conn.send(buf);
1245
- sent += buf.byteLength;
1370
+ sentBytes += buf.byteLength;
1246
1371
  if (dc) {
1247
1372
  while (dc.bufferedAmount > bufferHighWaterMark) {
1248
1373
  await new Promise((resolve) => {
@@ -1262,13 +1387,15 @@ async function startP2PSend(opts) {
1262
1387
  }
1263
1388
  }
1264
1389
  }
1265
- if (stopped) return;
1390
+ if (isStopped()) return;
1391
+ state = "finishing";
1266
1392
  conn.send({ t: "end" });
1267
1393
  const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
1268
1394
  const ackResult = await Promise.race([
1269
1395
  ackPromise,
1270
1396
  sleep(ackTimeoutMs || 15e3).catch(() => null)
1271
1397
  ]);
1398
+ if (isStopped()) return;
1272
1399
  if (!ackResult || typeof ackResult !== "object") {
1273
1400
  throw new DropgateNetworkError("Receiver did not confirm completion.");
1274
1401
  }
@@ -1279,29 +1406,43 @@ async function startP2PSend(opts) {
1279
1406
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
1280
1407
  }
1281
1408
  reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
1282
- transferCompleted = true;
1283
- transferActive = false;
1284
- onComplete?.();
1285
- stop();
1409
+ safeComplete();
1286
1410
  } catch (err) {
1287
- onError?.(err);
1288
- stop();
1411
+ safeError(err);
1289
1412
  }
1290
1413
  });
1291
1414
  conn.on("error", (err) => {
1292
- onError?.(err);
1293
- stop();
1415
+ safeError(err);
1294
1416
  });
1295
1417
  conn.on("close", () => {
1296
- if (!transferCompleted && transferActive && !stopped) {
1297
- onError?.(
1418
+ if (state === "closed" || state === "completed") {
1419
+ cleanup();
1420
+ return;
1421
+ }
1422
+ if (state === "transferring" || state === "finishing") {
1423
+ safeError(
1298
1424
  new DropgateNetworkError("Receiver disconnected before transfer completed.")
1299
1425
  );
1426
+ } else {
1427
+ activeConn = null;
1428
+ state = "listening";
1429
+ sentBytes = 0;
1430
+ onDisconnect?.();
1300
1431
  }
1301
- stop();
1302
1432
  });
1303
1433
  });
1304
- return { peer, code, stop };
1434
+ return {
1435
+ peer,
1436
+ code,
1437
+ sessionId,
1438
+ stop,
1439
+ getStatus: () => state,
1440
+ getBytesSent: () => sentBytes,
1441
+ getConnectedPeerId: () => {
1442
+ if (!activeConn) return null;
1443
+ return activeConn.peer || null;
1444
+ }
1445
+ };
1305
1446
  }
1306
1447
 
1307
1448
  // src/p2p/receive.ts
@@ -1315,6 +1456,8 @@ async function startP2PReceive(opts) {
1315
1456
  peerjsPath,
1316
1457
  secure = false,
1317
1458
  iceServers,
1459
+ autoReady = true,
1460
+ watchdogTimeoutMs = 15e3,
1318
1461
  onStatus,
1319
1462
  onMeta,
1320
1463
  onData,
@@ -1339,8 +1482,10 @@ async function startP2PReceive(opts) {
1339
1482
  if (!isP2PCodeLike(normalizedCode)) {
1340
1483
  throw new DropgateValidationError("Invalid direct transfer code.");
1341
1484
  }
1342
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1343
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1485
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1486
+ { peerjsPath, iceServers },
1487
+ p2pCaps
1488
+ );
1344
1489
  const peerOpts = buildPeerOptions({
1345
1490
  host,
1346
1491
  port,
@@ -1349,44 +1494,127 @@ async function startP2PReceive(opts) {
1349
1494
  iceServers: finalIceServers
1350
1495
  });
1351
1496
  const peer = new Peer(void 0, peerOpts);
1497
+ let state = "initializing";
1352
1498
  let total = 0;
1353
1499
  let received = 0;
1500
+ let currentSessionId = null;
1354
1501
  let lastProgressSentAt = 0;
1355
1502
  const progressIntervalMs = 120;
1356
1503
  let writeQueue = Promise.resolve();
1357
- const stop = () => {
1504
+ let watchdogTimer = null;
1505
+ let activeConn = null;
1506
+ const resetWatchdog = () => {
1507
+ if (watchdogTimeoutMs <= 0) return;
1508
+ if (watchdogTimer) {
1509
+ clearTimeout(watchdogTimer);
1510
+ }
1511
+ watchdogTimer = setTimeout(() => {
1512
+ if (state === "transferring") {
1513
+ safeError(new DropgateNetworkError("Connection timed out (no data received)."));
1514
+ }
1515
+ }, watchdogTimeoutMs);
1516
+ };
1517
+ const clearWatchdog = () => {
1518
+ if (watchdogTimer) {
1519
+ clearTimeout(watchdogTimer);
1520
+ watchdogTimer = null;
1521
+ }
1522
+ };
1523
+ const safeError = (err) => {
1524
+ if (state === "closed" || state === "completed") return;
1525
+ state = "closed";
1526
+ onError?.(err);
1527
+ cleanup();
1528
+ };
1529
+ const safeComplete = (completeData) => {
1530
+ if (state !== "transferring") return;
1531
+ state = "completed";
1532
+ onComplete?.(completeData);
1533
+ cleanup();
1534
+ };
1535
+ const cleanup = () => {
1536
+ clearWatchdog();
1537
+ if (typeof window !== "undefined") {
1538
+ window.removeEventListener("beforeunload", handleUnload);
1539
+ }
1358
1540
  try {
1359
1541
  peer.destroy();
1360
1542
  } catch {
1361
1543
  }
1362
1544
  };
1363
- peer.on("error", (err) => {
1364
- onError?.(err);
1545
+ const handleUnload = () => {
1546
+ try {
1547
+ activeConn?.send({ t: "error", message: "Receiver closed the connection." });
1548
+ } catch {
1549
+ }
1365
1550
  stop();
1551
+ };
1552
+ if (typeof window !== "undefined") {
1553
+ window.addEventListener("beforeunload", handleUnload);
1554
+ }
1555
+ const stop = () => {
1556
+ if (state === "closed") return;
1557
+ state = "closed";
1558
+ cleanup();
1559
+ };
1560
+ peer.on("error", (err) => {
1561
+ safeError(err);
1366
1562
  });
1367
1563
  peer.on("open", () => {
1564
+ state = "connecting";
1368
1565
  const conn = peer.connect(normalizedCode, { reliable: true });
1566
+ activeConn = conn;
1369
1567
  conn.on("open", () => {
1568
+ state = "negotiating";
1370
1569
  onStatus?.({ phase: "connected", message: "Waiting for file details..." });
1371
1570
  });
1372
1571
  conn.on("data", async (data) => {
1373
1572
  try {
1573
+ resetWatchdog();
1374
1574
  if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
1375
1575
  const msg = data;
1376
1576
  if (msg.t === "meta") {
1577
+ if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
1578
+ try {
1579
+ conn.send({ t: "error", message: "Busy with another session." });
1580
+ } catch {
1581
+ }
1582
+ return;
1583
+ }
1584
+ if (msg.sessionId) {
1585
+ currentSessionId = msg.sessionId;
1586
+ }
1377
1587
  const name = String(msg.name || "file");
1378
1588
  total = Number(msg.size) || 0;
1379
1589
  received = 0;
1380
1590
  writeQueue = Promise.resolve();
1381
- onMeta?.({ name, total });
1382
- onProgress?.({ received, total, percent: 0 });
1591
+ const sendReady = () => {
1592
+ state = "transferring";
1593
+ resetWatchdog();
1594
+ try {
1595
+ conn.send({ t: "ready" });
1596
+ } catch {
1597
+ }
1598
+ };
1599
+ if (autoReady) {
1600
+ onMeta?.({ name, total });
1601
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1602
+ sendReady();
1603
+ } else {
1604
+ onMeta?.({ name, total, sendReady });
1605
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1606
+ }
1607
+ return;
1608
+ }
1609
+ if (msg.t === "ping") {
1383
1610
  try {
1384
- conn.send({ t: "ready" });
1611
+ conn.send({ t: "pong" });
1385
1612
  } catch {
1386
1613
  }
1387
1614
  return;
1388
1615
  }
1389
1616
  if (msg.t === "end") {
1617
+ clearWatchdog();
1390
1618
  await writeQueue;
1391
1619
  if (total && received < total) {
1392
1620
  const err = new DropgateNetworkError(
@@ -1398,11 +1626,11 @@ async function startP2PReceive(opts) {
1398
1626
  }
1399
1627
  throw err;
1400
1628
  }
1401
- onComplete?.({ received, total });
1402
1629
  try {
1403
1630
  conn.send({ t: "ack", phase: "end", received, total });
1404
1631
  } catch {
1405
1632
  }
1633
+ safeComplete({ received, total });
1406
1634
  return;
1407
1635
  }
1408
1636
  if (msg.t === "error") {
@@ -1429,7 +1657,7 @@ async function startP2PReceive(opts) {
1429
1657
  }
1430
1658
  received += buf.byteLength;
1431
1659
  const percent = total ? Math.min(100, received / total * 100) : 0;
1432
- onProgress?.({ received, total, percent });
1660
+ onProgress?.({ processedBytes: received, totalBytes: total, percent });
1433
1661
  const now = Date.now();
1434
1662
  if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
1435
1663
  lastProgressSentAt = now;
@@ -1446,21 +1674,36 @@ async function startP2PReceive(opts) {
1446
1674
  });
1447
1675
  } catch {
1448
1676
  }
1449
- onError?.(err);
1450
- stop();
1677
+ safeError(err);
1451
1678
  });
1452
1679
  } catch (err) {
1453
- onError?.(err);
1454
- stop();
1680
+ safeError(err);
1455
1681
  }
1456
1682
  });
1457
1683
  conn.on("close", () => {
1458
- if (received > 0 && total > 0 && received < total) {
1684
+ if (state === "closed" || state === "completed") {
1685
+ cleanup();
1686
+ return;
1687
+ }
1688
+ if (state === "transferring") {
1689
+ safeError(new DropgateNetworkError("Sender disconnected during transfer."));
1690
+ } else if (state === "negotiating") {
1691
+ state = "closed";
1692
+ cleanup();
1459
1693
  onDisconnect?.();
1694
+ } else {
1695
+ safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
1460
1696
  }
1461
1697
  });
1462
1698
  });
1463
- return { peer, stop };
1699
+ return {
1700
+ peer,
1701
+ stop,
1702
+ getStatus: () => state,
1703
+ getBytesReceived: () => received,
1704
+ getTotalBytes: () => total,
1705
+ getSessionId: () => currentSessionId
1706
+ };
1464
1707
  }
1465
1708
  export {
1466
1709
  AES_GCM_IV_BYTES,
@@ -1492,6 +1735,7 @@ export {
1492
1735
  getDefaultBase64,
1493
1736
  getDefaultCrypto,
1494
1737
  getDefaultFetch,
1738
+ getServerInfo,
1495
1739
  importKeyFromBase64,
1496
1740
  isLocalhostHostname,
1497
1741
  isP2PCodeLike,
@@ -1500,6 +1744,7 @@ export {
1500
1744
  makeAbortSignal,
1501
1745
  parseSemverMajorMinor,
1502
1746
  parseServerUrl,
1747
+ resolvePeerConfig,
1503
1748
  sha256Hex,
1504
1749
  sleep,
1505
1750
  startP2PReceive,