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