@dropgate/core 2.0.0-beta.1 → 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/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,16 @@ 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
165
178
  } = opts;
166
179
  if (!file) {
167
180
  throw new DropgateValidationError("File is missing.");
@@ -175,8 +188,10 @@ async function startP2PSend(opts) {
175
188
  if (serverInfo && !p2pCaps?.enabled) {
176
189
  throw new DropgateValidationError("Direct transfer is disabled on this server.");
177
190
  }
178
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
179
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
191
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
192
+ { peerjsPath, iceServers },
193
+ p2pCaps
194
+ );
180
195
  const peerOpts = buildPeerOptions({
181
196
  host,
182
197
  port,
@@ -193,18 +208,37 @@ async function startP2PSend(opts) {
193
208
  buildPeer,
194
209
  onCode
195
210
  });
196
- let stopped = false;
211
+ const sessionId = generateSessionId();
212
+ let state = "listening";
197
213
  let activeConn = null;
198
- let transferActive = false;
199
- let transferCompleted = false;
214
+ let sentBytes = 0;
215
+ let heartbeatTimer = null;
200
216
  const reportProgress = (data) => {
201
217
  const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
202
218
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
203
219
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
204
- onProgress?.({ sent: safeReceived, total: safeTotal, percent });
220
+ onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
205
221
  };
206
- const stop = () => {
207
- stopped = true;
222
+ const safeError = (err) => {
223
+ if (state === "closed" || state === "completed") return;
224
+ state = "closed";
225
+ onError?.(err);
226
+ cleanup();
227
+ };
228
+ const safeComplete = () => {
229
+ if (state !== "finishing") return;
230
+ state = "completed";
231
+ onComplete?.();
232
+ cleanup();
233
+ };
234
+ const cleanup = () => {
235
+ if (heartbeatTimer) {
236
+ clearInterval(heartbeatTimer);
237
+ heartbeatTimer = null;
238
+ }
239
+ if (typeof window !== "undefined") {
240
+ window.removeEventListener("beforeunload", handleUnload);
241
+ }
208
242
  try {
209
243
  activeConn?.close();
210
244
  } catch {
@@ -214,21 +248,59 @@ async function startP2PSend(opts) {
214
248
  } catch {
215
249
  }
216
250
  };
251
+ const handleUnload = () => {
252
+ try {
253
+ activeConn?.send({ t: "error", message: "Sender closed the connection." });
254
+ } catch {
255
+ }
256
+ stop();
257
+ };
258
+ if (typeof window !== "undefined") {
259
+ window.addEventListener("beforeunload", handleUnload);
260
+ }
261
+ const stop = () => {
262
+ if (state === "closed") return;
263
+ state = "closed";
264
+ cleanup();
265
+ };
266
+ const isStopped = () => state === "closed";
217
267
  peer.on("connection", (conn) => {
218
- if (stopped) return;
268
+ if (state === "closed") return;
219
269
  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 {
270
+ const isOldConnOpen = activeConn.open !== false;
271
+ if (isOldConnOpen && state === "transferring") {
272
+ try {
273
+ conn.send({ t: "error", message: "Transfer already in progress." });
274
+ } catch {
275
+ }
276
+ try {
277
+ conn.close();
278
+ } catch {
279
+ }
280
+ return;
281
+ } else if (!isOldConnOpen) {
282
+ try {
283
+ activeConn.close();
284
+ } catch {
285
+ }
286
+ activeConn = null;
287
+ state = "listening";
288
+ sentBytes = 0;
289
+ } else {
290
+ try {
291
+ conn.send({ t: "error", message: "Another receiver is already connected." });
292
+ } catch {
293
+ }
294
+ try {
295
+ conn.close();
296
+ } catch {
297
+ }
298
+ return;
227
299
  }
228
- return;
229
300
  }
230
301
  activeConn = conn;
231
- onStatus?.({ phase: "connected", message: "Connected. Starting transfer..." });
302
+ state = "negotiating";
303
+ onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
232
304
  let readyResolve = null;
233
305
  let ackResolve = null;
234
306
  const readyPromise = new Promise((resolve) => {
@@ -244,6 +316,7 @@ async function startP2PSend(opts) {
244
316
  const msg = data;
245
317
  if (!msg.t) return;
246
318
  if (msg.t === "ready") {
319
+ onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
247
320
  readyResolve?.();
248
321
  return;
249
322
  }
@@ -255,22 +328,23 @@ async function startP2PSend(opts) {
255
328
  ackResolve?.(msg);
256
329
  return;
257
330
  }
331
+ if (msg.t === "pong") {
332
+ return;
333
+ }
258
334
  if (msg.t === "error") {
259
- onError?.(new DropgateNetworkError(msg.message || "Receiver reported an error."));
260
- stop();
335
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
261
336
  }
262
337
  });
263
338
  conn.on("open", async () => {
264
339
  try {
265
- transferActive = true;
266
- if (stopped) return;
340
+ if (isStopped()) return;
267
341
  conn.send({
268
342
  t: "meta",
343
+ sessionId,
269
344
  name: file.name,
270
345
  size: file.size,
271
346
  mime: file.type || "application/octet-stream"
272
347
  });
273
- let sent = 0;
274
348
  const total = file.size;
275
349
  const dc = conn._dc;
276
350
  if (dc && Number.isFinite(bufferLowWaterMark)) {
@@ -279,13 +353,25 @@ async function startP2PSend(opts) {
279
353
  } catch {
280
354
  }
281
355
  }
282
- await Promise.race([readyPromise, sleep(readyTimeoutMs).catch(() => null)]);
356
+ await readyPromise;
357
+ if (isStopped()) return;
358
+ if (heartbeatIntervalMs > 0) {
359
+ heartbeatTimer = setInterval(() => {
360
+ if (state === "transferring" || state === "finishing") {
361
+ try {
362
+ conn.send({ t: "ping" });
363
+ } catch {
364
+ }
365
+ }
366
+ }, heartbeatIntervalMs);
367
+ }
368
+ state = "transferring";
283
369
  for (let offset = 0; offset < total; offset += chunkSize) {
284
- if (stopped) return;
370
+ if (isStopped()) return;
285
371
  const slice = file.slice(offset, offset + chunkSize);
286
372
  const buf = await slice.arrayBuffer();
287
373
  conn.send(buf);
288
- sent += buf.byteLength;
374
+ sentBytes += buf.byteLength;
289
375
  if (dc) {
290
376
  while (dc.bufferedAmount > bufferHighWaterMark) {
291
377
  await new Promise((resolve) => {
@@ -305,13 +391,15 @@ async function startP2PSend(opts) {
305
391
  }
306
392
  }
307
393
  }
308
- if (stopped) return;
394
+ if (isStopped()) return;
395
+ state = "finishing";
309
396
  conn.send({ t: "end" });
310
397
  const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
311
398
  const ackResult = await Promise.race([
312
399
  ackPromise,
313
400
  sleep(ackTimeoutMs || 15e3).catch(() => null)
314
401
  ]);
402
+ if (isStopped()) return;
315
403
  if (!ackResult || typeof ackResult !== "object") {
316
404
  throw new DropgateNetworkError("Receiver did not confirm completion.");
317
405
  }
@@ -322,29 +410,43 @@ async function startP2PSend(opts) {
322
410
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
323
411
  }
324
412
  reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
325
- transferCompleted = true;
326
- transferActive = false;
327
- onComplete?.();
328
- stop();
413
+ safeComplete();
329
414
  } catch (err) {
330
- onError?.(err);
331
- stop();
415
+ safeError(err);
332
416
  }
333
417
  });
334
418
  conn.on("error", (err) => {
335
- onError?.(err);
336
- stop();
419
+ safeError(err);
337
420
  });
338
421
  conn.on("close", () => {
339
- if (!transferCompleted && transferActive && !stopped) {
340
- onError?.(
422
+ if (state === "closed" || state === "completed") {
423
+ cleanup();
424
+ return;
425
+ }
426
+ if (state === "transferring" || state === "finishing") {
427
+ safeError(
341
428
  new DropgateNetworkError("Receiver disconnected before transfer completed.")
342
429
  );
430
+ } else {
431
+ activeConn = null;
432
+ state = "listening";
433
+ sentBytes = 0;
434
+ onDisconnect?.();
343
435
  }
344
- stop();
345
436
  });
346
437
  });
347
- return { peer, code, stop };
438
+ return {
439
+ peer,
440
+ code,
441
+ sessionId,
442
+ stop,
443
+ getStatus: () => state,
444
+ getBytesSent: () => sentBytes,
445
+ getConnectedPeerId: () => {
446
+ if (!activeConn) return null;
447
+ return activeConn.peer || null;
448
+ }
449
+ };
348
450
  }
349
451
 
350
452
  // src/p2p/receive.ts
@@ -358,6 +460,8 @@ async function startP2PReceive(opts) {
358
460
  peerjsPath,
359
461
  secure = false,
360
462
  iceServers,
463
+ autoReady = true,
464
+ watchdogTimeoutMs = 15e3,
361
465
  onStatus,
362
466
  onMeta,
363
467
  onData,
@@ -382,8 +486,10 @@ async function startP2PReceive(opts) {
382
486
  if (!isP2PCodeLike(normalizedCode)) {
383
487
  throw new DropgateValidationError("Invalid direct transfer code.");
384
488
  }
385
- const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
386
- const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
489
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
490
+ { peerjsPath, iceServers },
491
+ p2pCaps
492
+ );
387
493
  const peerOpts = buildPeerOptions({
388
494
  host,
389
495
  port,
@@ -392,44 +498,127 @@ async function startP2PReceive(opts) {
392
498
  iceServers: finalIceServers
393
499
  });
394
500
  const peer = new Peer(void 0, peerOpts);
501
+ let state = "initializing";
395
502
  let total = 0;
396
503
  let received = 0;
504
+ let currentSessionId = null;
397
505
  let lastProgressSentAt = 0;
398
506
  const progressIntervalMs = 120;
399
507
  let writeQueue = Promise.resolve();
400
- const stop = () => {
508
+ let watchdogTimer = null;
509
+ let activeConn = null;
510
+ const resetWatchdog = () => {
511
+ if (watchdogTimeoutMs <= 0) return;
512
+ if (watchdogTimer) {
513
+ clearTimeout(watchdogTimer);
514
+ }
515
+ watchdogTimer = setTimeout(() => {
516
+ if (state === "transferring") {
517
+ safeError(new DropgateNetworkError("Connection timed out (no data received)."));
518
+ }
519
+ }, watchdogTimeoutMs);
520
+ };
521
+ const clearWatchdog = () => {
522
+ if (watchdogTimer) {
523
+ clearTimeout(watchdogTimer);
524
+ watchdogTimer = null;
525
+ }
526
+ };
527
+ const safeError = (err) => {
528
+ if (state === "closed" || state === "completed") return;
529
+ state = "closed";
530
+ onError?.(err);
531
+ cleanup();
532
+ };
533
+ const safeComplete = (completeData) => {
534
+ if (state !== "transferring") return;
535
+ state = "completed";
536
+ onComplete?.(completeData);
537
+ cleanup();
538
+ };
539
+ const cleanup = () => {
540
+ clearWatchdog();
541
+ if (typeof window !== "undefined") {
542
+ window.removeEventListener("beforeunload", handleUnload);
543
+ }
401
544
  try {
402
545
  peer.destroy();
403
546
  } catch {
404
547
  }
405
548
  };
406
- peer.on("error", (err) => {
407
- onError?.(err);
549
+ const handleUnload = () => {
550
+ try {
551
+ activeConn?.send({ t: "error", message: "Receiver closed the connection." });
552
+ } catch {
553
+ }
408
554
  stop();
555
+ };
556
+ if (typeof window !== "undefined") {
557
+ window.addEventListener("beforeunload", handleUnload);
558
+ }
559
+ const stop = () => {
560
+ if (state === "closed") return;
561
+ state = "closed";
562
+ cleanup();
563
+ };
564
+ peer.on("error", (err) => {
565
+ safeError(err);
409
566
  });
410
567
  peer.on("open", () => {
568
+ state = "connecting";
411
569
  const conn = peer.connect(normalizedCode, { reliable: true });
570
+ activeConn = conn;
412
571
  conn.on("open", () => {
572
+ state = "negotiating";
413
573
  onStatus?.({ phase: "connected", message: "Waiting for file details..." });
414
574
  });
415
575
  conn.on("data", async (data) => {
416
576
  try {
577
+ resetWatchdog();
417
578
  if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
418
579
  const msg = data;
419
580
  if (msg.t === "meta") {
581
+ if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
582
+ try {
583
+ conn.send({ t: "error", message: "Busy with another session." });
584
+ } catch {
585
+ }
586
+ return;
587
+ }
588
+ if (msg.sessionId) {
589
+ currentSessionId = msg.sessionId;
590
+ }
420
591
  const name = String(msg.name || "file");
421
592
  total = Number(msg.size) || 0;
422
593
  received = 0;
423
594
  writeQueue = Promise.resolve();
424
- onMeta?.({ name, total });
425
- onProgress?.({ received, total, percent: 0 });
595
+ const sendReady = () => {
596
+ state = "transferring";
597
+ resetWatchdog();
598
+ try {
599
+ conn.send({ t: "ready" });
600
+ } catch {
601
+ }
602
+ };
603
+ if (autoReady) {
604
+ onMeta?.({ name, total });
605
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
606
+ sendReady();
607
+ } else {
608
+ onMeta?.({ name, total, sendReady });
609
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
610
+ }
611
+ return;
612
+ }
613
+ if (msg.t === "ping") {
426
614
  try {
427
- conn.send({ t: "ready" });
615
+ conn.send({ t: "pong" });
428
616
  } catch {
429
617
  }
430
618
  return;
431
619
  }
432
620
  if (msg.t === "end") {
621
+ clearWatchdog();
433
622
  await writeQueue;
434
623
  if (total && received < total) {
435
624
  const err = new DropgateNetworkError(
@@ -441,11 +630,11 @@ async function startP2PReceive(opts) {
441
630
  }
442
631
  throw err;
443
632
  }
444
- onComplete?.({ received, total });
445
633
  try {
446
634
  conn.send({ t: "ack", phase: "end", received, total });
447
635
  } catch {
448
636
  }
637
+ safeComplete({ received, total });
449
638
  return;
450
639
  }
451
640
  if (msg.t === "error") {
@@ -472,7 +661,7 @@ async function startP2PReceive(opts) {
472
661
  }
473
662
  received += buf.byteLength;
474
663
  const percent = total ? Math.min(100, received / total * 100) : 0;
475
- onProgress?.({ received, total, percent });
664
+ onProgress?.({ processedBytes: received, totalBytes: total, percent });
476
665
  const now = Date.now();
477
666
  if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
478
667
  lastProgressSentAt = now;
@@ -489,21 +678,36 @@ async function startP2PReceive(opts) {
489
678
  });
490
679
  } catch {
491
680
  }
492
- onError?.(err);
493
- stop();
681
+ safeError(err);
494
682
  });
495
683
  } catch (err) {
496
- onError?.(err);
497
- stop();
684
+ safeError(err);
498
685
  }
499
686
  });
500
687
  conn.on("close", () => {
501
- if (received > 0 && total > 0 && received < total) {
688
+ if (state === "closed" || state === "completed") {
689
+ cleanup();
690
+ return;
691
+ }
692
+ if (state === "transferring") {
693
+ safeError(new DropgateNetworkError("Sender disconnected during transfer."));
694
+ } else if (state === "negotiating") {
695
+ state = "closed";
696
+ cleanup();
502
697
  onDisconnect?.();
698
+ } else {
699
+ safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
503
700
  }
504
701
  });
505
702
  });
506
- return { peer, stop };
703
+ return {
704
+ peer,
705
+ stop,
706
+ getStatus: () => state,
707
+ getBytesReceived: () => received,
708
+ getTotalBytes: () => total,
709
+ getSessionId: () => currentSessionId
710
+ };
507
711
  }
508
712
  export {
509
713
  buildPeerOptions,
@@ -512,6 +716,7 @@ export {
512
716
  isLocalhostHostname,
513
717
  isP2PCodeLike,
514
718
  isSecureContextForP2P,
719
+ resolvePeerConfig,
515
720
  startP2PReceive,
516
721
  startP2PSend
517
722
  };