@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.
@@ -26,6 +26,7 @@ __export(p2p_exports, {
26
26
  isLocalhostHostname: () => isLocalhostHostname,
27
27
  isP2PCodeLike: () => isP2PCodeLike,
28
28
  isSecureContextForP2P: () => isSecureContextForP2P,
29
+ resolvePeerConfig: () => resolvePeerConfig,
29
30
  startP2PReceive: () => startP2PReceive,
30
31
  startP2PSend: () => startP2PSend
31
32
  });
@@ -99,11 +100,11 @@ function isSecureContextForP2P(hostname, isSecureContext) {
99
100
  return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
100
101
  }
101
102
  function generateP2PCode(cryptoObj) {
102
- const crypto = cryptoObj || getDefaultCrypto();
103
+ const crypto2 = cryptoObj || getDefaultCrypto();
103
104
  const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
104
- if (crypto) {
105
+ if (crypto2) {
105
106
  const randomBytes = new Uint8Array(8);
106
- crypto.getRandomValues(randomBytes);
107
+ crypto2.getRandomValues(randomBytes);
107
108
  let letterPart = "";
108
109
  for (let i = 0; i < 4; i++) {
109
110
  letterPart += letters[randomBytes[i] % letters.length];
@@ -129,8 +130,14 @@ function isP2PCodeLike(code) {
129
130
  }
130
131
 
131
132
  // src/p2p/helpers.ts
132
- function buildPeerOptions(opts = {}) {
133
- const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = opts;
133
+ function resolvePeerConfig(userConfig, serverCaps) {
134
+ return {
135
+ path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
136
+ iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
137
+ };
138
+ }
139
+ function buildPeerOptions(config = {}) {
140
+ const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
134
141
  const peerOpts = {
135
142
  host,
136
143
  path: peerjsPath,
@@ -172,6 +179,12 @@ async function createPeerWithRetries(opts) {
172
179
  }
173
180
 
174
181
  // src/p2p/send.ts
182
+ function generateSessionId() {
183
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
184
+ return crypto.randomUUID();
185
+ }
186
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
187
+ }
175
188
  async function startP2PSend(opts) {
176
189
  const {
177
190
  file,
@@ -186,15 +199,17 @@ async function startP2PSend(opts) {
186
199
  cryptoObj,
187
200
  maxAttempts = 4,
188
201
  chunkSize = 256 * 1024,
189
- readyTimeoutMs = 8e3,
190
202
  endAckTimeoutMs = 15e3,
191
203
  bufferHighWaterMark = 8 * 1024 * 1024,
192
204
  bufferLowWaterMark = 2 * 1024 * 1024,
205
+ heartbeatIntervalMs = 5e3,
193
206
  onCode,
194
207
  onStatus,
195
208
  onProgress,
196
209
  onComplete,
197
- onError
210
+ onError,
211
+ onDisconnect,
212
+ onCancel
198
213
  } = opts;
199
214
  if (!file) {
200
215
  throw new DropgateValidationError("File is missing.");
@@ -208,8 +223,10 @@ async function startP2PSend(opts) {
208
223
  if (serverInfo && !p2pCaps?.enabled) {
209
224
  throw new DropgateValidationError("Direct transfer is disabled on this server.");
210
225
  }
211
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
212
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
226
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
227
+ { peerjsPath, iceServers },
228
+ p2pCaps
229
+ );
213
230
  const peerOpts = buildPeerOptions({
214
231
  host,
215
232
  port,
@@ -226,18 +243,37 @@ async function startP2PSend(opts) {
226
243
  buildPeer,
227
244
  onCode
228
245
  });
229
- let stopped = false;
246
+ const sessionId = generateSessionId();
247
+ let state = "listening";
230
248
  let activeConn = null;
231
- let transferActive = false;
232
- let transferCompleted = false;
249
+ let sentBytes = 0;
250
+ let heartbeatTimer = null;
233
251
  const reportProgress = (data) => {
234
252
  const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
235
253
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
236
254
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
237
- onProgress?.({ sent: safeReceived, total: safeTotal, percent });
255
+ onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
238
256
  };
239
- const stop = () => {
240
- stopped = true;
257
+ const safeError = (err) => {
258
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
259
+ state = "closed";
260
+ onError?.(err);
261
+ cleanup();
262
+ };
263
+ const safeComplete = () => {
264
+ if (state !== "finishing") return;
265
+ state = "completed";
266
+ onComplete?.();
267
+ cleanup();
268
+ };
269
+ const cleanup = () => {
270
+ if (heartbeatTimer) {
271
+ clearInterval(heartbeatTimer);
272
+ heartbeatTimer = null;
273
+ }
274
+ if (typeof window !== "undefined") {
275
+ window.removeEventListener("beforeunload", handleUnload);
276
+ }
241
277
  try {
242
278
  activeConn?.close();
243
279
  } catch {
@@ -247,21 +283,69 @@ async function startP2PSend(opts) {
247
283
  } catch {
248
284
  }
249
285
  };
286
+ const handleUnload = () => {
287
+ try {
288
+ activeConn?.send({ t: "error", message: "Sender closed the connection." });
289
+ } catch {
290
+ }
291
+ stop();
292
+ };
293
+ if (typeof window !== "undefined") {
294
+ window.addEventListener("beforeunload", handleUnload);
295
+ }
296
+ const stop = () => {
297
+ if (state === "closed" || state === "cancelled") return;
298
+ const wasActive = state === "transferring" || state === "finishing";
299
+ state = "cancelled";
300
+ try {
301
+ if (activeConn && activeConn.open) {
302
+ activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
303
+ }
304
+ } catch {
305
+ }
306
+ if (wasActive && onCancel) {
307
+ onCancel({ cancelledBy: "sender" });
308
+ }
309
+ cleanup();
310
+ };
311
+ const isStopped = () => state === "closed" || state === "cancelled";
250
312
  peer.on("connection", (conn) => {
251
- if (stopped) return;
313
+ if (state === "closed") return;
252
314
  if (activeConn) {
253
- try {
254
- conn.send({ t: "error", message: "Another receiver is already connected." });
255
- } catch {
256
- }
257
- try {
258
- conn.close();
259
- } catch {
315
+ const isOldConnOpen = activeConn.open !== false;
316
+ if (isOldConnOpen && state === "transferring") {
317
+ try {
318
+ conn.send({ t: "error", message: "Transfer already in progress." });
319
+ } catch {
320
+ }
321
+ try {
322
+ conn.close();
323
+ } catch {
324
+ }
325
+ return;
326
+ } else if (!isOldConnOpen) {
327
+ try {
328
+ activeConn.close();
329
+ } catch {
330
+ }
331
+ activeConn = null;
332
+ state = "listening";
333
+ sentBytes = 0;
334
+ } else {
335
+ try {
336
+ conn.send({ t: "error", message: "Another receiver is already connected." });
337
+ } catch {
338
+ }
339
+ try {
340
+ conn.close();
341
+ } catch {
342
+ }
343
+ return;
260
344
  }
261
- return;
262
345
  }
263
346
  activeConn = conn;
264
- onStatus?.({ phase: "connected", message: "Connected. Starting transfer..." });
347
+ state = "negotiating";
348
+ onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
265
349
  let readyResolve = null;
266
350
  let ackResolve = null;
267
351
  const readyPromise = new Promise((resolve) => {
@@ -277,6 +361,7 @@ async function startP2PSend(opts) {
277
361
  const msg = data;
278
362
  if (!msg.t) return;
279
363
  if (msg.t === "ready") {
364
+ onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
280
365
  readyResolve?.();
281
366
  return;
282
367
  }
@@ -288,22 +373,30 @@ async function startP2PSend(opts) {
288
373
  ackResolve?.(msg);
289
374
  return;
290
375
  }
376
+ if (msg.t === "pong") {
377
+ return;
378
+ }
291
379
  if (msg.t === "error") {
292
- onError?.(new DropgateNetworkError(msg.message || "Receiver reported an error."));
293
- stop();
380
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
381
+ return;
382
+ }
383
+ if (msg.t === "cancelled") {
384
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
385
+ state = "cancelled";
386
+ onCancel?.({ cancelledBy: "receiver", message: msg.message });
387
+ cleanup();
294
388
  }
295
389
  });
296
390
  conn.on("open", async () => {
297
391
  try {
298
- transferActive = true;
299
- if (stopped) return;
392
+ if (isStopped()) return;
300
393
  conn.send({
301
394
  t: "meta",
395
+ sessionId,
302
396
  name: file.name,
303
397
  size: file.size,
304
398
  mime: file.type || "application/octet-stream"
305
399
  });
306
- let sent = 0;
307
400
  const total = file.size;
308
401
  const dc = conn._dc;
309
402
  if (dc && Number.isFinite(bufferLowWaterMark)) {
@@ -312,13 +405,26 @@ async function startP2PSend(opts) {
312
405
  } catch {
313
406
  }
314
407
  }
315
- await Promise.race([readyPromise, sleep(readyTimeoutMs).catch(() => null)]);
408
+ await readyPromise;
409
+ if (isStopped()) return;
410
+ if (heartbeatIntervalMs > 0) {
411
+ heartbeatTimer = setInterval(() => {
412
+ if (state === "transferring" || state === "finishing") {
413
+ try {
414
+ conn.send({ t: "ping" });
415
+ } catch {
416
+ }
417
+ }
418
+ }, heartbeatIntervalMs);
419
+ }
420
+ state = "transferring";
316
421
  for (let offset = 0; offset < total; offset += chunkSize) {
317
- if (stopped) return;
422
+ if (isStopped()) return;
318
423
  const slice = file.slice(offset, offset + chunkSize);
319
424
  const buf = await slice.arrayBuffer();
425
+ if (isStopped()) return;
320
426
  conn.send(buf);
321
- sent += buf.byteLength;
427
+ sentBytes += buf.byteLength;
322
428
  if (dc) {
323
429
  while (dc.bufferedAmount > bufferHighWaterMark) {
324
430
  await new Promise((resolve) => {
@@ -338,13 +444,15 @@ async function startP2PSend(opts) {
338
444
  }
339
445
  }
340
446
  }
341
- if (stopped) return;
447
+ if (isStopped()) return;
448
+ state = "finishing";
342
449
  conn.send({ t: "end" });
343
450
  const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
344
451
  const ackResult = await Promise.race([
345
452
  ackPromise,
346
453
  sleep(ackTimeoutMs || 15e3).catch(() => null)
347
454
  ]);
455
+ if (isStopped()) return;
348
456
  if (!ackResult || typeof ackResult !== "object") {
349
457
  throw new DropgateNetworkError("Receiver did not confirm completion.");
350
458
  }
@@ -355,29 +463,43 @@ async function startP2PSend(opts) {
355
463
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
356
464
  }
357
465
  reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
358
- transferCompleted = true;
359
- transferActive = false;
360
- onComplete?.();
361
- stop();
466
+ safeComplete();
362
467
  } catch (err) {
363
- onError?.(err);
364
- stop();
468
+ safeError(err);
365
469
  }
366
470
  });
367
471
  conn.on("error", (err) => {
368
- onError?.(err);
369
- stop();
472
+ safeError(err);
370
473
  });
371
474
  conn.on("close", () => {
372
- if (!transferCompleted && transferActive && !stopped) {
373
- onError?.(
374
- new DropgateNetworkError("Receiver disconnected before transfer completed.")
375
- );
475
+ if (state === "closed" || state === "completed" || state === "cancelled") {
476
+ cleanup();
477
+ return;
478
+ }
479
+ if (state === "transferring" || state === "finishing") {
480
+ state = "cancelled";
481
+ onCancel?.({ cancelledBy: "receiver" });
482
+ cleanup();
483
+ } else {
484
+ activeConn = null;
485
+ state = "listening";
486
+ sentBytes = 0;
487
+ onDisconnect?.();
376
488
  }
377
- stop();
378
489
  });
379
490
  });
380
- return { peer, code, stop };
491
+ return {
492
+ peer,
493
+ code,
494
+ sessionId,
495
+ stop,
496
+ getStatus: () => state,
497
+ getBytesSent: () => sentBytes,
498
+ getConnectedPeerId: () => {
499
+ if (!activeConn) return null;
500
+ return activeConn.peer || null;
501
+ }
502
+ };
381
503
  }
382
504
 
383
505
  // src/p2p/receive.ts
@@ -391,13 +513,16 @@ async function startP2PReceive(opts) {
391
513
  peerjsPath,
392
514
  secure = false,
393
515
  iceServers,
516
+ autoReady = true,
517
+ watchdogTimeoutMs = 15e3,
394
518
  onStatus,
395
519
  onMeta,
396
520
  onData,
397
521
  onProgress,
398
522
  onComplete,
399
523
  onError,
400
- onDisconnect
524
+ onDisconnect,
525
+ onCancel
401
526
  } = opts;
402
527
  if (!code) {
403
528
  throw new DropgateValidationError("No sharing code was provided.");
@@ -415,8 +540,10 @@ async function startP2PReceive(opts) {
415
540
  if (!isP2PCodeLike(normalizedCode)) {
416
541
  throw new DropgateValidationError("Invalid direct transfer code.");
417
542
  }
418
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
419
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
543
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
544
+ { peerjsPath, iceServers },
545
+ p2pCaps
546
+ );
420
547
  const peerOpts = buildPeerOptions({
421
548
  host,
422
549
  port,
@@ -425,44 +552,137 @@ async function startP2PReceive(opts) {
425
552
  iceServers: finalIceServers
426
553
  });
427
554
  const peer = new Peer(void 0, peerOpts);
555
+ let state = "initializing";
428
556
  let total = 0;
429
557
  let received = 0;
558
+ let currentSessionId = null;
430
559
  let lastProgressSentAt = 0;
431
560
  const progressIntervalMs = 120;
432
561
  let writeQueue = Promise.resolve();
433
- const stop = () => {
562
+ let watchdogTimer = null;
563
+ let activeConn = null;
564
+ const resetWatchdog = () => {
565
+ if (watchdogTimeoutMs <= 0) return;
566
+ if (watchdogTimer) {
567
+ clearTimeout(watchdogTimer);
568
+ }
569
+ watchdogTimer = setTimeout(() => {
570
+ if (state === "transferring") {
571
+ safeError(new DropgateNetworkError("Connection timed out (no data received)."));
572
+ }
573
+ }, watchdogTimeoutMs);
574
+ };
575
+ const clearWatchdog = () => {
576
+ if (watchdogTimer) {
577
+ clearTimeout(watchdogTimer);
578
+ watchdogTimer = null;
579
+ }
580
+ };
581
+ const safeError = (err) => {
582
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
583
+ state = "closed";
584
+ onError?.(err);
585
+ cleanup();
586
+ };
587
+ const safeComplete = (completeData) => {
588
+ if (state !== "transferring") return;
589
+ state = "completed";
590
+ onComplete?.(completeData);
591
+ cleanup();
592
+ };
593
+ const cleanup = () => {
594
+ clearWatchdog();
595
+ if (typeof window !== "undefined") {
596
+ window.removeEventListener("beforeunload", handleUnload);
597
+ }
434
598
  try {
435
599
  peer.destroy();
436
600
  } catch {
437
601
  }
438
602
  };
439
- peer.on("error", (err) => {
440
- onError?.(err);
603
+ const handleUnload = () => {
604
+ try {
605
+ activeConn?.send({ t: "error", message: "Receiver closed the connection." });
606
+ } catch {
607
+ }
441
608
  stop();
609
+ };
610
+ if (typeof window !== "undefined") {
611
+ window.addEventListener("beforeunload", handleUnload);
612
+ }
613
+ const stop = () => {
614
+ if (state === "closed" || state === "cancelled") return;
615
+ const wasActive = state === "transferring";
616
+ state = "cancelled";
617
+ try {
618
+ if (activeConn && activeConn.open) {
619
+ activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
620
+ }
621
+ } catch {
622
+ }
623
+ if (wasActive && onCancel) {
624
+ onCancel({ cancelledBy: "receiver" });
625
+ }
626
+ cleanup();
627
+ };
628
+ peer.on("error", (err) => {
629
+ safeError(err);
442
630
  });
443
631
  peer.on("open", () => {
632
+ state = "connecting";
444
633
  const conn = peer.connect(normalizedCode, { reliable: true });
634
+ activeConn = conn;
445
635
  conn.on("open", () => {
636
+ state = "negotiating";
446
637
  onStatus?.({ phase: "connected", message: "Waiting for file details..." });
447
638
  });
448
639
  conn.on("data", async (data) => {
449
640
  try {
641
+ resetWatchdog();
450
642
  if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
451
643
  const msg = data;
452
644
  if (msg.t === "meta") {
645
+ if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
646
+ try {
647
+ conn.send({ t: "error", message: "Busy with another session." });
648
+ } catch {
649
+ }
650
+ return;
651
+ }
652
+ if (msg.sessionId) {
653
+ currentSessionId = msg.sessionId;
654
+ }
453
655
  const name = String(msg.name || "file");
454
656
  total = Number(msg.size) || 0;
455
657
  received = 0;
456
658
  writeQueue = Promise.resolve();
457
- onMeta?.({ name, total });
458
- onProgress?.({ received, total, percent: 0 });
659
+ const sendReady = () => {
660
+ state = "transferring";
661
+ resetWatchdog();
662
+ try {
663
+ conn.send({ t: "ready" });
664
+ } catch {
665
+ }
666
+ };
667
+ if (autoReady) {
668
+ onMeta?.({ name, total });
669
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
670
+ sendReady();
671
+ } else {
672
+ onMeta?.({ name, total, sendReady });
673
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
674
+ }
675
+ return;
676
+ }
677
+ if (msg.t === "ping") {
459
678
  try {
460
- conn.send({ t: "ready" });
679
+ conn.send({ t: "pong" });
461
680
  } catch {
462
681
  }
463
682
  return;
464
683
  }
465
684
  if (msg.t === "end") {
685
+ clearWatchdog();
466
686
  await writeQueue;
467
687
  if (total && received < total) {
468
688
  const err = new DropgateNetworkError(
@@ -474,16 +694,23 @@ async function startP2PReceive(opts) {
474
694
  }
475
695
  throw err;
476
696
  }
477
- onComplete?.({ received, total });
478
697
  try {
479
698
  conn.send({ t: "ack", phase: "end", received, total });
480
699
  } catch {
481
700
  }
701
+ safeComplete({ received, total });
482
702
  return;
483
703
  }
484
704
  if (msg.t === "error") {
485
705
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
486
706
  }
707
+ if (msg.t === "cancelled") {
708
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
709
+ state = "cancelled";
710
+ onCancel?.({ cancelledBy: "sender", message: msg.message });
711
+ cleanup();
712
+ return;
713
+ }
487
714
  return;
488
715
  }
489
716
  let bufPromise;
@@ -505,7 +732,7 @@ async function startP2PReceive(opts) {
505
732
  }
506
733
  received += buf.byteLength;
507
734
  const percent = total ? Math.min(100, received / total * 100) : 0;
508
- onProgress?.({ received, total, percent });
735
+ onProgress?.({ processedBytes: received, totalBytes: total, percent });
509
736
  const now = Date.now();
510
737
  if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
511
738
  lastProgressSentAt = now;
@@ -522,21 +749,38 @@ async function startP2PReceive(opts) {
522
749
  });
523
750
  } catch {
524
751
  }
525
- onError?.(err);
526
- stop();
752
+ safeError(err);
527
753
  });
528
754
  } catch (err) {
529
- onError?.(err);
530
- stop();
755
+ safeError(err);
531
756
  }
532
757
  });
533
758
  conn.on("close", () => {
534
- if (received > 0 && total > 0 && received < total) {
759
+ if (state === "closed" || state === "completed" || state === "cancelled") {
760
+ cleanup();
761
+ return;
762
+ }
763
+ if (state === "transferring") {
764
+ state = "cancelled";
765
+ onCancel?.({ cancelledBy: "sender" });
766
+ cleanup();
767
+ } else if (state === "negotiating") {
768
+ state = "closed";
769
+ cleanup();
535
770
  onDisconnect?.();
771
+ } else {
772
+ safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
536
773
  }
537
774
  });
538
775
  });
539
- return { peer, stop };
776
+ return {
777
+ peer,
778
+ stop,
779
+ getStatus: () => state,
780
+ getBytesReceived: () => received,
781
+ getTotalBytes: () => total,
782
+ getSessionId: () => currentSessionId
783
+ };
540
784
  }
541
785
  // Annotate the CommonJS export names for ESM import in node:
542
786
  0 && (module.exports = {
@@ -546,6 +790,7 @@ async function startP2PReceive(opts) {
546
790
  isLocalhostHostname,
547
791
  isP2PCodeLike,
548
792
  isSecureContextForP2P,
793
+ resolvePeerConfig,
549
794
  startP2PReceive,
550
795
  startP2PSend
551
796
  });