@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.
@@ -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,16 @@ 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
198
212
  } = opts;
199
213
  if (!file) {
200
214
  throw new DropgateValidationError("File is missing.");
@@ -208,8 +222,10 @@ async function startP2PSend(opts) {
208
222
  if (serverInfo && !p2pCaps?.enabled) {
209
223
  throw new DropgateValidationError("Direct transfer is disabled on this server.");
210
224
  }
211
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
212
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
225
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
226
+ { peerjsPath, iceServers },
227
+ p2pCaps
228
+ );
213
229
  const peerOpts = buildPeerOptions({
214
230
  host,
215
231
  port,
@@ -226,18 +242,37 @@ async function startP2PSend(opts) {
226
242
  buildPeer,
227
243
  onCode
228
244
  });
229
- let stopped = false;
245
+ const sessionId = generateSessionId();
246
+ let state = "listening";
230
247
  let activeConn = null;
231
- let transferActive = false;
232
- let transferCompleted = false;
248
+ let sentBytes = 0;
249
+ let heartbeatTimer = null;
233
250
  const reportProgress = (data) => {
234
251
  const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
235
252
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
236
253
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
237
- onProgress?.({ sent: safeReceived, total: safeTotal, percent });
254
+ onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
238
255
  };
239
- const stop = () => {
240
- stopped = true;
256
+ const safeError = (err) => {
257
+ if (state === "closed" || state === "completed") return;
258
+ state = "closed";
259
+ onError?.(err);
260
+ cleanup();
261
+ };
262
+ const safeComplete = () => {
263
+ if (state !== "finishing") return;
264
+ state = "completed";
265
+ onComplete?.();
266
+ cleanup();
267
+ };
268
+ const cleanup = () => {
269
+ if (heartbeatTimer) {
270
+ clearInterval(heartbeatTimer);
271
+ heartbeatTimer = null;
272
+ }
273
+ if (typeof window !== "undefined") {
274
+ window.removeEventListener("beforeunload", handleUnload);
275
+ }
241
276
  try {
242
277
  activeConn?.close();
243
278
  } catch {
@@ -247,21 +282,59 @@ async function startP2PSend(opts) {
247
282
  } catch {
248
283
  }
249
284
  };
285
+ const handleUnload = () => {
286
+ try {
287
+ activeConn?.send({ t: "error", message: "Sender closed the connection." });
288
+ } catch {
289
+ }
290
+ stop();
291
+ };
292
+ if (typeof window !== "undefined") {
293
+ window.addEventListener("beforeunload", handleUnload);
294
+ }
295
+ const stop = () => {
296
+ if (state === "closed") return;
297
+ state = "closed";
298
+ cleanup();
299
+ };
300
+ const isStopped = () => state === "closed";
250
301
  peer.on("connection", (conn) => {
251
- if (stopped) return;
302
+ if (state === "closed") return;
252
303
  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 {
304
+ const isOldConnOpen = activeConn.open !== false;
305
+ if (isOldConnOpen && state === "transferring") {
306
+ try {
307
+ conn.send({ t: "error", message: "Transfer already in progress." });
308
+ } catch {
309
+ }
310
+ try {
311
+ conn.close();
312
+ } catch {
313
+ }
314
+ return;
315
+ } else if (!isOldConnOpen) {
316
+ try {
317
+ activeConn.close();
318
+ } catch {
319
+ }
320
+ activeConn = null;
321
+ state = "listening";
322
+ sentBytes = 0;
323
+ } else {
324
+ try {
325
+ conn.send({ t: "error", message: "Another receiver is already connected." });
326
+ } catch {
327
+ }
328
+ try {
329
+ conn.close();
330
+ } catch {
331
+ }
332
+ return;
260
333
  }
261
- return;
262
334
  }
263
335
  activeConn = conn;
264
- onStatus?.({ phase: "connected", message: "Connected. Starting transfer..." });
336
+ state = "negotiating";
337
+ onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
265
338
  let readyResolve = null;
266
339
  let ackResolve = null;
267
340
  const readyPromise = new Promise((resolve) => {
@@ -277,6 +350,7 @@ async function startP2PSend(opts) {
277
350
  const msg = data;
278
351
  if (!msg.t) return;
279
352
  if (msg.t === "ready") {
353
+ onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
280
354
  readyResolve?.();
281
355
  return;
282
356
  }
@@ -288,22 +362,23 @@ async function startP2PSend(opts) {
288
362
  ackResolve?.(msg);
289
363
  return;
290
364
  }
365
+ if (msg.t === "pong") {
366
+ return;
367
+ }
291
368
  if (msg.t === "error") {
292
- onError?.(new DropgateNetworkError(msg.message || "Receiver reported an error."));
293
- stop();
369
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
294
370
  }
295
371
  });
296
372
  conn.on("open", async () => {
297
373
  try {
298
- transferActive = true;
299
- if (stopped) return;
374
+ if (isStopped()) return;
300
375
  conn.send({
301
376
  t: "meta",
377
+ sessionId,
302
378
  name: file.name,
303
379
  size: file.size,
304
380
  mime: file.type || "application/octet-stream"
305
381
  });
306
- let sent = 0;
307
382
  const total = file.size;
308
383
  const dc = conn._dc;
309
384
  if (dc && Number.isFinite(bufferLowWaterMark)) {
@@ -312,13 +387,25 @@ async function startP2PSend(opts) {
312
387
  } catch {
313
388
  }
314
389
  }
315
- await Promise.race([readyPromise, sleep(readyTimeoutMs).catch(() => null)]);
390
+ await readyPromise;
391
+ if (isStopped()) return;
392
+ if (heartbeatIntervalMs > 0) {
393
+ heartbeatTimer = setInterval(() => {
394
+ if (state === "transferring" || state === "finishing") {
395
+ try {
396
+ conn.send({ t: "ping" });
397
+ } catch {
398
+ }
399
+ }
400
+ }, heartbeatIntervalMs);
401
+ }
402
+ state = "transferring";
316
403
  for (let offset = 0; offset < total; offset += chunkSize) {
317
- if (stopped) return;
404
+ if (isStopped()) return;
318
405
  const slice = file.slice(offset, offset + chunkSize);
319
406
  const buf = await slice.arrayBuffer();
320
407
  conn.send(buf);
321
- sent += buf.byteLength;
408
+ sentBytes += buf.byteLength;
322
409
  if (dc) {
323
410
  while (dc.bufferedAmount > bufferHighWaterMark) {
324
411
  await new Promise((resolve) => {
@@ -338,13 +425,15 @@ async function startP2PSend(opts) {
338
425
  }
339
426
  }
340
427
  }
341
- if (stopped) return;
428
+ if (isStopped()) return;
429
+ state = "finishing";
342
430
  conn.send({ t: "end" });
343
431
  const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
344
432
  const ackResult = await Promise.race([
345
433
  ackPromise,
346
434
  sleep(ackTimeoutMs || 15e3).catch(() => null)
347
435
  ]);
436
+ if (isStopped()) return;
348
437
  if (!ackResult || typeof ackResult !== "object") {
349
438
  throw new DropgateNetworkError("Receiver did not confirm completion.");
350
439
  }
@@ -355,29 +444,43 @@ async function startP2PSend(opts) {
355
444
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
356
445
  }
357
446
  reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
358
- transferCompleted = true;
359
- transferActive = false;
360
- onComplete?.();
361
- stop();
447
+ safeComplete();
362
448
  } catch (err) {
363
- onError?.(err);
364
- stop();
449
+ safeError(err);
365
450
  }
366
451
  });
367
452
  conn.on("error", (err) => {
368
- onError?.(err);
369
- stop();
453
+ safeError(err);
370
454
  });
371
455
  conn.on("close", () => {
372
- if (!transferCompleted && transferActive && !stopped) {
373
- onError?.(
456
+ if (state === "closed" || state === "completed") {
457
+ cleanup();
458
+ return;
459
+ }
460
+ if (state === "transferring" || state === "finishing") {
461
+ safeError(
374
462
  new DropgateNetworkError("Receiver disconnected before transfer completed.")
375
463
  );
464
+ } else {
465
+ activeConn = null;
466
+ state = "listening";
467
+ sentBytes = 0;
468
+ onDisconnect?.();
376
469
  }
377
- stop();
378
470
  });
379
471
  });
380
- return { peer, code, stop };
472
+ return {
473
+ peer,
474
+ code,
475
+ sessionId,
476
+ stop,
477
+ getStatus: () => state,
478
+ getBytesSent: () => sentBytes,
479
+ getConnectedPeerId: () => {
480
+ if (!activeConn) return null;
481
+ return activeConn.peer || null;
482
+ }
483
+ };
381
484
  }
382
485
 
383
486
  // src/p2p/receive.ts
@@ -391,6 +494,8 @@ async function startP2PReceive(opts) {
391
494
  peerjsPath,
392
495
  secure = false,
393
496
  iceServers,
497
+ autoReady = true,
498
+ watchdogTimeoutMs = 15e3,
394
499
  onStatus,
395
500
  onMeta,
396
501
  onData,
@@ -415,8 +520,10 @@ async function startP2PReceive(opts) {
415
520
  if (!isP2PCodeLike(normalizedCode)) {
416
521
  throw new DropgateValidationError("Invalid direct transfer code.");
417
522
  }
418
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
419
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
523
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
524
+ { peerjsPath, iceServers },
525
+ p2pCaps
526
+ );
420
527
  const peerOpts = buildPeerOptions({
421
528
  host,
422
529
  port,
@@ -425,44 +532,127 @@ async function startP2PReceive(opts) {
425
532
  iceServers: finalIceServers
426
533
  });
427
534
  const peer = new Peer(void 0, peerOpts);
535
+ let state = "initializing";
428
536
  let total = 0;
429
537
  let received = 0;
538
+ let currentSessionId = null;
430
539
  let lastProgressSentAt = 0;
431
540
  const progressIntervalMs = 120;
432
541
  let writeQueue = Promise.resolve();
433
- const stop = () => {
542
+ let watchdogTimer = null;
543
+ let activeConn = null;
544
+ const resetWatchdog = () => {
545
+ if (watchdogTimeoutMs <= 0) return;
546
+ if (watchdogTimer) {
547
+ clearTimeout(watchdogTimer);
548
+ }
549
+ watchdogTimer = setTimeout(() => {
550
+ if (state === "transferring") {
551
+ safeError(new DropgateNetworkError("Connection timed out (no data received)."));
552
+ }
553
+ }, watchdogTimeoutMs);
554
+ };
555
+ const clearWatchdog = () => {
556
+ if (watchdogTimer) {
557
+ clearTimeout(watchdogTimer);
558
+ watchdogTimer = null;
559
+ }
560
+ };
561
+ const safeError = (err) => {
562
+ if (state === "closed" || state === "completed") return;
563
+ state = "closed";
564
+ onError?.(err);
565
+ cleanup();
566
+ };
567
+ const safeComplete = (completeData) => {
568
+ if (state !== "transferring") return;
569
+ state = "completed";
570
+ onComplete?.(completeData);
571
+ cleanup();
572
+ };
573
+ const cleanup = () => {
574
+ clearWatchdog();
575
+ if (typeof window !== "undefined") {
576
+ window.removeEventListener("beforeunload", handleUnload);
577
+ }
434
578
  try {
435
579
  peer.destroy();
436
580
  } catch {
437
581
  }
438
582
  };
439
- peer.on("error", (err) => {
440
- onError?.(err);
583
+ const handleUnload = () => {
584
+ try {
585
+ activeConn?.send({ t: "error", message: "Receiver closed the connection." });
586
+ } catch {
587
+ }
441
588
  stop();
589
+ };
590
+ if (typeof window !== "undefined") {
591
+ window.addEventListener("beforeunload", handleUnload);
592
+ }
593
+ const stop = () => {
594
+ if (state === "closed") return;
595
+ state = "closed";
596
+ cleanup();
597
+ };
598
+ peer.on("error", (err) => {
599
+ safeError(err);
442
600
  });
443
601
  peer.on("open", () => {
602
+ state = "connecting";
444
603
  const conn = peer.connect(normalizedCode, { reliable: true });
604
+ activeConn = conn;
445
605
  conn.on("open", () => {
606
+ state = "negotiating";
446
607
  onStatus?.({ phase: "connected", message: "Waiting for file details..." });
447
608
  });
448
609
  conn.on("data", async (data) => {
449
610
  try {
611
+ resetWatchdog();
450
612
  if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
451
613
  const msg = data;
452
614
  if (msg.t === "meta") {
615
+ if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
616
+ try {
617
+ conn.send({ t: "error", message: "Busy with another session." });
618
+ } catch {
619
+ }
620
+ return;
621
+ }
622
+ if (msg.sessionId) {
623
+ currentSessionId = msg.sessionId;
624
+ }
453
625
  const name = String(msg.name || "file");
454
626
  total = Number(msg.size) || 0;
455
627
  received = 0;
456
628
  writeQueue = Promise.resolve();
457
- onMeta?.({ name, total });
458
- onProgress?.({ received, total, percent: 0 });
629
+ const sendReady = () => {
630
+ state = "transferring";
631
+ resetWatchdog();
632
+ try {
633
+ conn.send({ t: "ready" });
634
+ } catch {
635
+ }
636
+ };
637
+ if (autoReady) {
638
+ onMeta?.({ name, total });
639
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
640
+ sendReady();
641
+ } else {
642
+ onMeta?.({ name, total, sendReady });
643
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
644
+ }
645
+ return;
646
+ }
647
+ if (msg.t === "ping") {
459
648
  try {
460
- conn.send({ t: "ready" });
649
+ conn.send({ t: "pong" });
461
650
  } catch {
462
651
  }
463
652
  return;
464
653
  }
465
654
  if (msg.t === "end") {
655
+ clearWatchdog();
466
656
  await writeQueue;
467
657
  if (total && received < total) {
468
658
  const err = new DropgateNetworkError(
@@ -474,11 +664,11 @@ async function startP2PReceive(opts) {
474
664
  }
475
665
  throw err;
476
666
  }
477
- onComplete?.({ received, total });
478
667
  try {
479
668
  conn.send({ t: "ack", phase: "end", received, total });
480
669
  } catch {
481
670
  }
671
+ safeComplete({ received, total });
482
672
  return;
483
673
  }
484
674
  if (msg.t === "error") {
@@ -505,7 +695,7 @@ async function startP2PReceive(opts) {
505
695
  }
506
696
  received += buf.byteLength;
507
697
  const percent = total ? Math.min(100, received / total * 100) : 0;
508
- onProgress?.({ received, total, percent });
698
+ onProgress?.({ processedBytes: received, totalBytes: total, percent });
509
699
  const now = Date.now();
510
700
  if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
511
701
  lastProgressSentAt = now;
@@ -522,21 +712,36 @@ async function startP2PReceive(opts) {
522
712
  });
523
713
  } catch {
524
714
  }
525
- onError?.(err);
526
- stop();
715
+ safeError(err);
527
716
  });
528
717
  } catch (err) {
529
- onError?.(err);
530
- stop();
718
+ safeError(err);
531
719
  }
532
720
  });
533
721
  conn.on("close", () => {
534
- if (received > 0 && total > 0 && received < total) {
722
+ if (state === "closed" || state === "completed") {
723
+ cleanup();
724
+ return;
725
+ }
726
+ if (state === "transferring") {
727
+ safeError(new DropgateNetworkError("Sender disconnected during transfer."));
728
+ } else if (state === "negotiating") {
729
+ state = "closed";
730
+ cleanup();
535
731
  onDisconnect?.();
732
+ } else {
733
+ safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
536
734
  }
537
735
  });
538
736
  });
539
- return { peer, stop };
737
+ return {
738
+ peer,
739
+ stop,
740
+ getStatus: () => state,
741
+ getBytesReceived: () => received,
742
+ getTotalBytes: () => total,
743
+ getSessionId: () => currentSessionId
744
+ };
540
745
  }
541
746
  // Annotate the CommonJS export names for ESM import in node:
542
747
  0 && (module.exports = {
@@ -546,6 +751,7 @@ async function startP2PReceive(opts) {
546
751
  isLocalhostHostname,
547
752
  isP2PCodeLike,
548
753
  isSecureContextForP2P,
754
+ resolvePeerConfig,
549
755
  startP2PReceive,
550
756
  startP2PSend
551
757
  });