@dropgate/core 2.2.0 → 3.0.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.cjs CHANGED
@@ -3,6 +3,7 @@ var __defProp = Object.defineProperty;
3
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
7
  var __export = (target, all) => {
7
8
  for (var name in all)
8
9
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -16,6 +17,7 @@ var __copyProps = (to, from, except, desc) => {
16
17
  return to;
17
18
  };
18
19
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
19
21
 
20
22
  // src/index.ts
21
23
  var index_exports = {};
@@ -31,12 +33,11 @@ __export(index_exports, {
31
33
  DropgateTimeoutError: () => DropgateTimeoutError,
32
34
  DropgateValidationError: () => DropgateValidationError,
33
35
  ENCRYPTION_OVERHEAD_PER_CHUNK: () => ENCRYPTION_OVERHEAD_PER_CHUNK,
36
+ StreamingZipWriter: () => StreamingZipWriter,
34
37
  arrayBufferToBase64: () => arrayBufferToBase64,
35
38
  base64ToBytes: () => base64ToBytes,
36
39
  buildBaseUrl: () => buildBaseUrl,
37
- buildPeerOptions: () => buildPeerOptions,
38
40
  bytesToBase64: () => bytesToBase64,
39
- createPeerWithRetries: () => createPeerWithRetries,
40
41
  decryptChunk: () => decryptChunk,
41
42
  decryptFilenameFromBase64: () => decryptFilenameFromBase64,
42
43
  encryptFilenameToBase64: () => encryptFilenameToBase64,
@@ -58,11 +59,8 @@ __export(index_exports, {
58
59
  makeAbortSignal: () => makeAbortSignal,
59
60
  parseSemverMajorMinor: () => parseSemverMajorMinor,
60
61
  parseServerUrl: () => parseServerUrl,
61
- resolvePeerConfig: () => resolvePeerConfig,
62
62
  sha256Hex: () => sha256Hex,
63
63
  sleep: () => sleep,
64
- startP2PReceive: () => startP2PReceive,
65
- startP2PSend: () => startP2PSend,
66
64
  validatePlainFilename: () => validatePlainFilename
67
65
  });
68
66
  module.exports = __toCommonJS(index_exports);
@@ -77,18 +75,12 @@ var MAX_IN_MEMORY_DOWNLOAD_BYTES = 100 * 1024 * 1024;
77
75
  // src/errors.ts
78
76
  var DropgateError = class extends Error {
79
77
  constructor(message, opts = {}) {
80
- super(message);
78
+ super(message, opts.cause !== void 0 ? { cause: opts.cause } : void 0);
79
+ __publicField(this, "code");
80
+ __publicField(this, "details");
81
81
  this.name = this.constructor.name;
82
82
  this.code = opts.code || "DROPGATE_ERROR";
83
83
  this.details = opts.details;
84
- if (opts.cause !== void 0) {
85
- Object.defineProperty(this, "cause", {
86
- value: opts.cause,
87
- writable: false,
88
- enumerable: false,
89
- configurable: true
90
- });
91
- }
92
84
  }
93
85
  };
94
86
  var DropgateValidationError = class extends DropgateError {
@@ -304,6 +296,144 @@ async function fetchJson(fetchFn, url, opts = {}) {
304
296
  }
305
297
  }
306
298
 
299
+ // src/crypto/sha256-fallback.ts
300
+ var K = new Uint32Array([
301
+ 1116352408,
302
+ 1899447441,
303
+ 3049323471,
304
+ 3921009573,
305
+ 961987163,
306
+ 1508970993,
307
+ 2453635748,
308
+ 2870763221,
309
+ 3624381080,
310
+ 310598401,
311
+ 607225278,
312
+ 1426881987,
313
+ 1925078388,
314
+ 2162078206,
315
+ 2614888103,
316
+ 3248222580,
317
+ 3835390401,
318
+ 4022224774,
319
+ 264347078,
320
+ 604807628,
321
+ 770255983,
322
+ 1249150122,
323
+ 1555081692,
324
+ 1996064986,
325
+ 2554220882,
326
+ 2821834349,
327
+ 2952996808,
328
+ 3210313671,
329
+ 3336571891,
330
+ 3584528711,
331
+ 113926993,
332
+ 338241895,
333
+ 666307205,
334
+ 773529912,
335
+ 1294757372,
336
+ 1396182291,
337
+ 1695183700,
338
+ 1986661051,
339
+ 2177026350,
340
+ 2456956037,
341
+ 2730485921,
342
+ 2820302411,
343
+ 3259730800,
344
+ 3345764771,
345
+ 3516065817,
346
+ 3600352804,
347
+ 4094571909,
348
+ 275423344,
349
+ 430227734,
350
+ 506948616,
351
+ 659060556,
352
+ 883997877,
353
+ 958139571,
354
+ 1322822218,
355
+ 1537002063,
356
+ 1747873779,
357
+ 1955562222,
358
+ 2024104815,
359
+ 2227730452,
360
+ 2361852424,
361
+ 2428436474,
362
+ 2756734187,
363
+ 3204031479,
364
+ 3329325298
365
+ ]);
366
+ function rotr(x, n) {
367
+ return x >>> n | x << 32 - n;
368
+ }
369
+ function sha256Fallback(data) {
370
+ const bytes = new Uint8Array(data);
371
+ const bitLen = bytes.length * 8;
372
+ const padded = new Uint8Array(
373
+ Math.ceil((bytes.length + 9) / 64) * 64
374
+ );
375
+ padded.set(bytes);
376
+ padded[bytes.length] = 128;
377
+ const view = new DataView(padded.buffer);
378
+ view.setUint32(padded.length - 8, bitLen / 4294967296 >>> 0, false);
379
+ view.setUint32(padded.length - 4, bitLen >>> 0, false);
380
+ let h0 = 1779033703;
381
+ let h1 = 3144134277;
382
+ let h2 = 1013904242;
383
+ let h3 = 2773480762;
384
+ let h4 = 1359893119;
385
+ let h5 = 2600822924;
386
+ let h6 = 528734635;
387
+ let h7 = 1541459225;
388
+ const W = new Uint32Array(64);
389
+ for (let offset = 0; offset < padded.length; offset += 64) {
390
+ for (let i = 0; i < 16; i++) {
391
+ W[i] = view.getUint32(offset + i * 4, false);
392
+ }
393
+ for (let i = 16; i < 64; i++) {
394
+ const s0 = rotr(W[i - 15], 7) ^ rotr(W[i - 15], 18) ^ W[i - 15] >>> 3;
395
+ const s1 = rotr(W[i - 2], 17) ^ rotr(W[i - 2], 19) ^ W[i - 2] >>> 10;
396
+ W[i] = W[i - 16] + s0 + W[i - 7] + s1 | 0;
397
+ }
398
+ let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
399
+ for (let i = 0; i < 64; i++) {
400
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
401
+ const ch = e & f ^ ~e & g;
402
+ const temp1 = h + S1 + ch + K[i] + W[i] | 0;
403
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
404
+ const maj = a & b ^ a & c ^ b & c;
405
+ const temp2 = S0 + maj | 0;
406
+ h = g;
407
+ g = f;
408
+ f = e;
409
+ e = d + temp1 | 0;
410
+ d = c;
411
+ c = b;
412
+ b = a;
413
+ a = temp1 + temp2 | 0;
414
+ }
415
+ h0 = h0 + a | 0;
416
+ h1 = h1 + b | 0;
417
+ h2 = h2 + c | 0;
418
+ h3 = h3 + d | 0;
419
+ h4 = h4 + e | 0;
420
+ h5 = h5 + f | 0;
421
+ h6 = h6 + g | 0;
422
+ h7 = h7 + h | 0;
423
+ }
424
+ const result = new ArrayBuffer(32);
425
+ const out = new DataView(result);
426
+ out.setUint32(0, h0, false);
427
+ out.setUint32(4, h1, false);
428
+ out.setUint32(8, h2, false);
429
+ out.setUint32(12, h3, false);
430
+ out.setUint32(16, h4, false);
431
+ out.setUint32(20, h5, false);
432
+ out.setUint32(24, h6, false);
433
+ out.setUint32(28, h7, false);
434
+ return result;
435
+ }
436
+
307
437
  // src/crypto/decrypt.ts
308
438
  async function importKeyFromBase64(cryptoObj, keyB64, base64) {
309
439
  const adapter = base64 || getDefaultBase64();
@@ -334,8 +464,7 @@ async function decryptFilenameFromBase64(cryptoObj, encryptedFilenameB64, key, b
334
464
  }
335
465
 
336
466
  // src/crypto/index.ts
337
- async function sha256Hex(cryptoObj, data) {
338
- const hashBuffer = await cryptoObj.subtle.digest("SHA-256", data);
467
+ function digestToHex(hashBuffer) {
339
468
  const arr = new Uint8Array(hashBuffer);
340
469
  let hex = "";
341
470
  for (let i = 0; i < arr.length; i++) {
@@ -343,6 +472,13 @@ async function sha256Hex(cryptoObj, data) {
343
472
  }
344
473
  return hex;
345
474
  }
475
+ async function sha256Hex(cryptoObj, data) {
476
+ if (cryptoObj?.subtle) {
477
+ const hashBuffer = await cryptoObj.subtle.digest("SHA-256", data);
478
+ return digestToHex(hashBuffer);
479
+ }
480
+ return digestToHex(sha256Fallback(data));
481
+ }
346
482
  async function generateAesGcmKey(cryptoObj) {
347
483
  return cryptoObj.subtle.generateKey(
348
484
  { name: "AES-GCM", length: 256 },
@@ -372,1546 +508,2788 @@ async function encryptFilenameToBase64(cryptoObj, filename, key) {
372
508
  return arrayBufferToBase64(buf);
373
509
  }
374
510
 
375
- // src/client/DropgateClient.ts
376
- function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
377
- const base = Number(fileSizeBytes) || 0;
378
- if (!isEncrypted) return base;
379
- return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
380
- }
381
- async function getServerInfo(opts) {
382
- const { host, port, secure, timeoutMs = 5e3, signal, fetchFn: customFetch } = opts;
383
- const fetchFn = customFetch || getDefaultFetch();
384
- if (!fetchFn) {
385
- throw new DropgateValidationError("No fetch() implementation found.");
511
+ // node_modules/fflate/esm/browser.js
512
+ var u8 = Uint8Array;
513
+ var u16 = Uint16Array;
514
+ var i32 = Int32Array;
515
+ var fleb = new u8([
516
+ 0,
517
+ 0,
518
+ 0,
519
+ 0,
520
+ 0,
521
+ 0,
522
+ 0,
523
+ 0,
524
+ 1,
525
+ 1,
526
+ 1,
527
+ 1,
528
+ 2,
529
+ 2,
530
+ 2,
531
+ 2,
532
+ 3,
533
+ 3,
534
+ 3,
535
+ 3,
536
+ 4,
537
+ 4,
538
+ 4,
539
+ 4,
540
+ 5,
541
+ 5,
542
+ 5,
543
+ 5,
544
+ 0,
545
+ /* unused */
546
+ 0,
547
+ 0,
548
+ /* impossible */
549
+ 0
550
+ ]);
551
+ var fdeb = new u8([
552
+ 0,
553
+ 0,
554
+ 0,
555
+ 0,
556
+ 1,
557
+ 1,
558
+ 2,
559
+ 2,
560
+ 3,
561
+ 3,
562
+ 4,
563
+ 4,
564
+ 5,
565
+ 5,
566
+ 6,
567
+ 6,
568
+ 7,
569
+ 7,
570
+ 8,
571
+ 8,
572
+ 9,
573
+ 9,
574
+ 10,
575
+ 10,
576
+ 11,
577
+ 11,
578
+ 12,
579
+ 12,
580
+ 13,
581
+ 13,
582
+ /* unused */
583
+ 0,
584
+ 0
585
+ ]);
586
+ var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
587
+ var freb = function(eb, start) {
588
+ var b = new u16(31);
589
+ for (var i = 0; i < 31; ++i) {
590
+ b[i] = start += 1 << eb[i - 1];
386
591
  }
387
- const baseUrl = buildBaseUrl({ host, port, secure });
388
- try {
389
- const { res, json } = await fetchJson(
390
- fetchFn,
391
- `${baseUrl}/api/info`,
392
- {
393
- method: "GET",
394
- timeoutMs,
395
- signal,
396
- headers: { Accept: "application/json" }
397
- }
398
- );
399
- if (res.ok && json && typeof json === "object" && "version" in json) {
400
- return { baseUrl, serverInfo: json };
592
+ var r = new i32(b[30]);
593
+ for (var i = 1; i < 30; ++i) {
594
+ for (var j = b[i]; j < b[i + 1]; ++j) {
595
+ r[j] = j - b[i] << 5 | i;
401
596
  }
402
- throw new DropgateProtocolError(
403
- `Server info request failed (status ${res.status}).`
404
- );
405
- } catch (err) {
406
- if (err instanceof DropgateError) throw err;
407
- throw new DropgateNetworkError("Could not reach server /api/info.", {
408
- cause: err
409
- });
410
597
  }
598
+ return { b, r };
599
+ };
600
+ var _a = freb(fleb, 2);
601
+ var fl = _a.b;
602
+ var revfl = _a.r;
603
+ fl[28] = 258, revfl[258] = 28;
604
+ var _b = freb(fdeb, 0);
605
+ var fd = _b.b;
606
+ var revfd = _b.r;
607
+ var rev = new u16(32768);
608
+ for (i = 0; i < 32768; ++i) {
609
+ x = (i & 43690) >> 1 | (i & 21845) << 1;
610
+ x = (x & 52428) >> 2 | (x & 13107) << 2;
611
+ x = (x & 61680) >> 4 | (x & 3855) << 4;
612
+ rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
411
613
  }
412
- var DropgateClient = class {
413
- /**
414
- * Create a new DropgateClient instance.
415
- * @param opts - Client configuration options.
416
- * @throws {DropgateValidationError} If clientVersion is missing or invalid.
417
- */
418
- constructor(opts) {
419
- if (!opts || typeof opts.clientVersion !== "string") {
420
- throw new DropgateValidationError(
421
- "DropgateClient requires clientVersion (string)."
422
- );
614
+ var x;
615
+ var i;
616
+ var flt = new u8(288);
617
+ for (i = 0; i < 144; ++i)
618
+ flt[i] = 8;
619
+ var i;
620
+ for (i = 144; i < 256; ++i)
621
+ flt[i] = 9;
622
+ var i;
623
+ for (i = 256; i < 280; ++i)
624
+ flt[i] = 7;
625
+ var i;
626
+ for (i = 280; i < 288; ++i)
627
+ flt[i] = 8;
628
+ var i;
629
+ var fdt = new u8(32);
630
+ for (i = 0; i < 32; ++i)
631
+ fdt[i] = 5;
632
+ var i;
633
+ var slc = function(v, s, e) {
634
+ if (s == null || s < 0)
635
+ s = 0;
636
+ if (e == null || e > v.length)
637
+ e = v.length;
638
+ return new u8(v.subarray(s, e));
639
+ };
640
+ var ec = [
641
+ "unexpected EOF",
642
+ "invalid block type",
643
+ "invalid length/literal",
644
+ "invalid distance",
645
+ "stream finished",
646
+ "no stream handler",
647
+ ,
648
+ "no callback",
649
+ "invalid UTF-8 data",
650
+ "extra field too long",
651
+ "date not in range 1980-2099",
652
+ "filename too long",
653
+ "stream finishing",
654
+ "invalid zip data"
655
+ // determined by unknown compression method
656
+ ];
657
+ var err = function(ind, msg, nt) {
658
+ var e = new Error(msg || ec[ind]);
659
+ e.code = ind;
660
+ if (Error.captureStackTrace)
661
+ Error.captureStackTrace(e, err);
662
+ if (!nt)
663
+ throw e;
664
+ return e;
665
+ };
666
+ var et = /* @__PURE__ */ new u8(0);
667
+ var crct = /* @__PURE__ */ (function() {
668
+ var t = new Int32Array(256);
669
+ for (var i = 0; i < 256; ++i) {
670
+ var c = i, k = 9;
671
+ while (--k)
672
+ c = (c & 1 && -306674912) ^ c >>> 1;
673
+ t[i] = c;
674
+ }
675
+ return t;
676
+ })();
677
+ var crc = function() {
678
+ var c = -1;
679
+ return {
680
+ p: function(d) {
681
+ var cr = c;
682
+ for (var i = 0; i < d.length; ++i)
683
+ cr = crct[cr & 255 ^ d[i]] ^ cr >>> 8;
684
+ c = cr;
685
+ },
686
+ d: function() {
687
+ return ~c;
423
688
  }
424
- this.clientVersion = opts.clientVersion;
425
- this.chunkSize = Number.isFinite(opts.chunkSize) ? opts.chunkSize : DEFAULT_CHUNK_SIZE;
426
- const fetchFn = opts.fetchFn || getDefaultFetch();
427
- if (!fetchFn) {
428
- throw new DropgateValidationError("No fetch() implementation found.");
689
+ };
690
+ };
691
+ var mrg = function(a, b) {
692
+ var o = {};
693
+ for (var k in a)
694
+ o[k] = a[k];
695
+ for (var k in b)
696
+ o[k] = b[k];
697
+ return o;
698
+ };
699
+ var wbytes = function(d, b, v) {
700
+ for (; v; ++b)
701
+ d[b] = v, v >>>= 8;
702
+ };
703
+ var te = typeof TextEncoder != "undefined" && /* @__PURE__ */ new TextEncoder();
704
+ var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
705
+ var tds = 0;
706
+ try {
707
+ td.decode(et, { stream: true });
708
+ tds = 1;
709
+ } catch (e) {
710
+ }
711
+ function strToU8(str, latin1) {
712
+ if (latin1) {
713
+ var ar_1 = new u8(str.length);
714
+ for (var i = 0; i < str.length; ++i)
715
+ ar_1[i] = str.charCodeAt(i);
716
+ return ar_1;
717
+ }
718
+ if (te)
719
+ return te.encode(str);
720
+ var l = str.length;
721
+ var ar = new u8(str.length + (str.length >> 1));
722
+ var ai = 0;
723
+ var w = function(v) {
724
+ ar[ai++] = v;
725
+ };
726
+ for (var i = 0; i < l; ++i) {
727
+ if (ai + 5 > ar.length) {
728
+ var n = new u8(ai + 8 + (l - i << 1));
729
+ n.set(ar);
730
+ ar = n;
429
731
  }
430
- this.fetchFn = fetchFn;
431
- const cryptoObj = opts.cryptoObj || getDefaultCrypto();
432
- if (!cryptoObj) {
433
- throw new DropgateValidationError("No crypto implementation found.");
732
+ var c = str.charCodeAt(i);
733
+ if (c < 128 || latin1)
734
+ w(c);
735
+ else if (c < 2048)
736
+ w(192 | c >> 6), w(128 | c & 63);
737
+ else if (c > 55295 && c < 57344)
738
+ c = 65536 + (c & 1023 << 10) | str.charCodeAt(++i) & 1023, w(240 | c >> 18), w(128 | c >> 12 & 63), w(128 | c >> 6 & 63), w(128 | c & 63);
739
+ else
740
+ w(224 | c >> 12), w(128 | c >> 6 & 63), w(128 | c & 63);
741
+ }
742
+ return slc(ar, 0, ai);
743
+ }
744
+ var exfl = function(ex) {
745
+ var le = 0;
746
+ if (ex) {
747
+ for (var k in ex) {
748
+ var l = ex[k].length;
749
+ if (l > 65535)
750
+ err(9);
751
+ le += l + 4;
434
752
  }
435
- this.cryptoObj = cryptoObj;
436
- this.base64 = opts.base64 || getDefaultBase64();
437
- this.logger = opts.logger || null;
438
753
  }
439
- /**
440
- * Resolve a user-entered sharing code or URL via the server.
441
- * @param value - The sharing code or URL to resolve.
442
- * @param opts - Server target and request options.
443
- * @returns The resolved share target information.
444
- * @throws {DropgateProtocolError} If the share lookup fails.
445
- */
446
- async resolveShareTarget(value, opts) {
447
- const { timeoutMs = 5e3, signal } = opts;
448
- const compat = await this.checkCompatibility(opts);
449
- if (!compat.compatible) {
450
- throw new DropgateValidationError(compat.message);
754
+ return le;
755
+ };
756
+ var wzh = function(d, b, f, fn, u, c, ce, co) {
757
+ var fl2 = fn.length, ex = f.extra, col = co && co.length;
758
+ var exl = exfl(ex);
759
+ wbytes(d, b, ce != null ? 33639248 : 67324752), b += 4;
760
+ if (ce != null)
761
+ d[b++] = 20, d[b++] = f.os;
762
+ d[b] = 20, b += 2;
763
+ d[b++] = f.flag << 1 | (c < 0 && 8), d[b++] = u && 8;
764
+ d[b++] = f.compression & 255, d[b++] = f.compression >> 8;
765
+ var dt = new Date(f.mtime == null ? Date.now() : f.mtime), y = dt.getFullYear() - 1980;
766
+ if (y < 0 || y > 119)
767
+ err(10);
768
+ wbytes(d, b, y << 25 | dt.getMonth() + 1 << 21 | dt.getDate() << 16 | dt.getHours() << 11 | dt.getMinutes() << 5 | dt.getSeconds() >> 1), b += 4;
769
+ if (c != -1) {
770
+ wbytes(d, b, f.crc);
771
+ wbytes(d, b + 4, c < 0 ? -c - 2 : c);
772
+ wbytes(d, b + 8, f.size);
773
+ }
774
+ wbytes(d, b + 12, fl2);
775
+ wbytes(d, b + 14, exl), b += 16;
776
+ if (ce != null) {
777
+ wbytes(d, b, col);
778
+ wbytes(d, b + 6, f.attrs);
779
+ wbytes(d, b + 10, ce), b += 14;
780
+ }
781
+ d.set(fn, b);
782
+ b += fl2;
783
+ if (exl) {
784
+ for (var k in ex) {
785
+ var exf = ex[k], l = exf.length;
786
+ wbytes(d, b, +k);
787
+ wbytes(d, b + 2, l);
788
+ d.set(exf, b + 4), b += 4 + l;
451
789
  }
452
- const { baseUrl } = compat;
453
- const { res, json } = await fetchJson(
454
- this.fetchFn,
455
- `${baseUrl}/api/resolve`,
456
- {
457
- method: "POST",
458
- timeoutMs,
459
- signal,
460
- headers: {
461
- "Content-Type": "application/json",
462
- Accept: "application/json"
790
+ }
791
+ if (col)
792
+ d.set(co, b), b += col;
793
+ return b;
794
+ };
795
+ var wzf = function(o, b, c, d, e) {
796
+ wbytes(o, b, 101010256);
797
+ wbytes(o, b + 8, c);
798
+ wbytes(o, b + 10, c);
799
+ wbytes(o, b + 12, d);
800
+ wbytes(o, b + 16, e);
801
+ };
802
+ var ZipPassThrough = /* @__PURE__ */ (function() {
803
+ function ZipPassThrough2(filename) {
804
+ this.filename = filename;
805
+ this.c = crc();
806
+ this.size = 0;
807
+ this.compression = 0;
808
+ }
809
+ ZipPassThrough2.prototype.process = function(chunk, final) {
810
+ this.ondata(null, chunk, final);
811
+ };
812
+ ZipPassThrough2.prototype.push = function(chunk, final) {
813
+ if (!this.ondata)
814
+ err(5);
815
+ this.c.p(chunk);
816
+ this.size += chunk.length;
817
+ if (final)
818
+ this.crc = this.c.d();
819
+ this.process(chunk, final || false);
820
+ };
821
+ return ZipPassThrough2;
822
+ })();
823
+ var Zip = /* @__PURE__ */ (function() {
824
+ function Zip2(cb) {
825
+ this.ondata = cb;
826
+ this.u = [];
827
+ this.d = 1;
828
+ }
829
+ Zip2.prototype.add = function(file) {
830
+ var _this = this;
831
+ if (!this.ondata)
832
+ err(5);
833
+ if (this.d & 2)
834
+ this.ondata(err(4 + (this.d & 1) * 8, 0, 1), null, false);
835
+ else {
836
+ var f = strToU8(file.filename), fl_1 = f.length;
837
+ var com = file.comment, o = com && strToU8(com);
838
+ var u = fl_1 != file.filename.length || o && com.length != o.length;
839
+ var hl_1 = fl_1 + exfl(file.extra) + 30;
840
+ if (fl_1 > 65535)
841
+ this.ondata(err(11, 0, 1), null, false);
842
+ var header = new u8(hl_1);
843
+ wzh(header, 0, file, f, u, -1);
844
+ var chks_1 = [header];
845
+ var pAll_1 = function() {
846
+ for (var _i = 0, chks_2 = chks_1; _i < chks_2.length; _i++) {
847
+ var chk = chks_2[_i];
848
+ _this.ondata(null, chk, false);
849
+ }
850
+ chks_1 = [];
851
+ };
852
+ var tr_1 = this.d;
853
+ this.d = 0;
854
+ var ind_1 = this.u.length;
855
+ var uf_1 = mrg(file, {
856
+ f,
857
+ u,
858
+ o,
859
+ t: function() {
860
+ if (file.terminate)
861
+ file.terminate();
463
862
  },
464
- body: JSON.stringify({ value })
465
- }
466
- );
467
- if (!res.ok) {
468
- const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Share lookup failed (status ${res.status}).`;
469
- throw new DropgateProtocolError(msg, { details: json });
863
+ r: function() {
864
+ pAll_1();
865
+ if (tr_1) {
866
+ var nxt = _this.u[ind_1 + 1];
867
+ if (nxt)
868
+ nxt.r();
869
+ else
870
+ _this.d = 1;
871
+ }
872
+ tr_1 = 1;
873
+ }
874
+ });
875
+ var cl_1 = 0;
876
+ file.ondata = function(err2, dat, final) {
877
+ if (err2) {
878
+ _this.ondata(err2, dat, final);
879
+ _this.terminate();
880
+ } else {
881
+ cl_1 += dat.length;
882
+ chks_1.push(dat);
883
+ if (final) {
884
+ var dd = new u8(16);
885
+ wbytes(dd, 0, 134695760);
886
+ wbytes(dd, 4, file.crc);
887
+ wbytes(dd, 8, cl_1);
888
+ wbytes(dd, 12, file.size);
889
+ chks_1.push(dd);
890
+ uf_1.c = cl_1, uf_1.b = hl_1 + cl_1 + 16, uf_1.crc = file.crc, uf_1.size = file.size;
891
+ if (tr_1)
892
+ uf_1.r();
893
+ tr_1 = 1;
894
+ } else if (tr_1)
895
+ pAll_1();
896
+ }
897
+ };
898
+ this.u.push(uf_1);
470
899
  }
471
- return json || { valid: false, reason: "Unknown response." };
472
- }
473
- /**
474
- * Check version compatibility between this client and a server.
475
- * Fetches server info internally using getServerInfo.
476
- * @param opts - Server target and request options.
477
- * @returns Compatibility result with status, message, and server info.
478
- * @throws {DropgateNetworkError} If the server cannot be reached.
479
- * @throws {DropgateProtocolError} If the server returns an invalid response.
480
- */
481
- async checkCompatibility(opts) {
482
- let baseUrl;
483
- let serverInfo;
484
- try {
485
- const result = await getServerInfo({ ...opts, fetchFn: this.fetchFn });
486
- baseUrl = result.baseUrl;
487
- serverInfo = result.serverInfo;
488
- } catch (err) {
489
- if (err instanceof DropgateError) throw err;
490
- throw new DropgateNetworkError("Could not connect to the server.", {
491
- cause: err
900
+ };
901
+ Zip2.prototype.end = function() {
902
+ var _this = this;
903
+ if (this.d & 2) {
904
+ this.ondata(err(4 + (this.d & 1) * 8, 0, 1), null, true);
905
+ return;
906
+ }
907
+ if (this.d)
908
+ this.e();
909
+ else
910
+ this.u.push({
911
+ r: function() {
912
+ if (!(_this.d & 1))
913
+ return;
914
+ _this.u.splice(-1, 1);
915
+ _this.e();
916
+ },
917
+ t: function() {
918
+ }
492
919
  });
920
+ this.d = 3;
921
+ };
922
+ Zip2.prototype.e = function() {
923
+ var bt = 0, l = 0, tl = 0;
924
+ for (var _i = 0, _a2 = this.u; _i < _a2.length; _i++) {
925
+ var f = _a2[_i];
926
+ tl += 46 + f.f.length + exfl(f.extra) + (f.o ? f.o.length : 0);
493
927
  }
494
- const serverVersion = String(serverInfo?.version || "0.0.0");
495
- const clientVersion = String(this.clientVersion || "0.0.0");
496
- const c = parseSemverMajorMinor(clientVersion);
497
- const s = parseSemverMajorMinor(serverVersion);
498
- if (c.major !== s.major) {
499
- return {
500
- compatible: false,
501
- clientVersion,
502
- serverVersion,
503
- message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
504
- serverInfo,
505
- baseUrl
506
- };
928
+ var out = new u8(tl + 22);
929
+ for (var _b2 = 0, _c = this.u; _b2 < _c.length; _b2++) {
930
+ var f = _c[_b2];
931
+ wzh(out, bt, f, f.f, f.u, -f.c - 2, l, f.o);
932
+ bt += 46 + f.f.length + exfl(f.extra) + (f.o ? f.o.length : 0), l += f.b;
507
933
  }
508
- if (c.minor > s.minor) {
509
- return {
510
- compatible: true,
511
- clientVersion,
512
- serverVersion,
513
- message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`,
514
- serverInfo,
515
- baseUrl
516
- };
934
+ wzf(out, bt, this.u.length, tl, l);
935
+ this.ondata(null, out, true);
936
+ this.d = 2;
937
+ };
938
+ Zip2.prototype.terminate = function() {
939
+ for (var _i = 0, _a2 = this.u; _i < _a2.length; _i++) {
940
+ var f = _a2[_i];
941
+ f.t();
517
942
  }
518
- return {
519
- compatible: true,
520
- clientVersion,
521
- serverVersion,
522
- message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
523
- serverInfo,
524
- baseUrl
525
- };
943
+ this.d = 2;
944
+ };
945
+ return Zip2;
946
+ })();
947
+
948
+ // src/zip/stream-zip.ts
949
+ var StreamingZipWriter = class {
950
+ constructor(onData) {
951
+ __publicField(this, "zip");
952
+ __publicField(this, "currentFile", null);
953
+ __publicField(this, "onData");
954
+ __publicField(this, "finalized", false);
955
+ __publicField(this, "pendingWrites", Promise.resolve());
956
+ this.onData = onData;
957
+ this.zip = new Zip((err2, data, _final) => {
958
+ if (err2) throw err2;
959
+ this.pendingWrites = this.pendingWrites.then(() => this.onData(data));
960
+ });
526
961
  }
527
962
  /**
528
- * Validate file and upload settings against server capabilities.
529
- * @param opts - Validation options containing file, settings, and server info.
530
- * @returns True if validation passes.
531
- * @throws {DropgateValidationError} If any validation check fails.
963
+ * Begin a new file entry in the ZIP.
964
+ * Must call endFile() before starting another file.
965
+ * @param name - Filename within the ZIP archive.
532
966
  */
533
- validateUploadInputs(opts) {
534
- const { file, lifetimeMs, encrypt, serverInfo } = opts;
535
- const caps = serverInfo?.capabilities?.upload;
536
- if (!caps || !caps.enabled) {
537
- throw new DropgateValidationError("Server does not support file uploads.");
538
- }
539
- const fileSize = Number(file?.size || 0);
540
- if (!file || !Number.isFinite(fileSize) || fileSize <= 0) {
541
- throw new DropgateValidationError("File is missing or invalid.");
542
- }
543
- const maxMB = Number(caps.maxSizeMB);
544
- if (Number.isFinite(maxMB) && maxMB > 0) {
545
- const limitBytes = maxMB * 1e3 * 1e3;
546
- const totalChunks = Math.ceil(fileSize / this.chunkSize);
547
- const estimatedBytes = estimateTotalUploadSizeBytes(
548
- fileSize,
549
- totalChunks,
550
- Boolean(encrypt)
551
- );
552
- if (estimatedBytes > limitBytes) {
553
- const msg = encrypt ? `File too large once encryption overhead is included. Server limit: ${maxMB} MB.` : `File too large. Server limit: ${maxMB} MB.`;
554
- throw new DropgateValidationError(msg);
555
- }
967
+ startFile(name) {
968
+ if (this.currentFile) {
969
+ throw new Error("Must call endFile() before starting a new file.");
556
970
  }
557
- const maxHours = Number(caps.maxLifetimeHours);
558
- const lt = Number(lifetimeMs);
559
- if (!Number.isFinite(lt) || lt < 0 || !Number.isInteger(lt)) {
560
- throw new DropgateValidationError(
561
- "Invalid lifetime. Must be a non-negative integer (milliseconds)."
562
- );
971
+ if (this.finalized) {
972
+ throw new Error("ZIP has already been finalized.");
563
973
  }
564
- if (Number.isFinite(maxHours) && maxHours > 0) {
565
- const limitMs = Math.round(maxHours * 60 * 60 * 1e3);
566
- if (lt === 0) {
567
- throw new DropgateValidationError(
568
- `Server does not allow unlimited file lifetime. Max: ${maxHours} hours.`
569
- );
570
- }
571
- if (lt > limitMs) {
572
- throw new DropgateValidationError(
573
- `File lifetime too long. Server limit: ${maxHours} hours.`
574
- );
575
- }
974
+ const entry = new ZipPassThrough(name);
975
+ this.zip.add(entry);
976
+ this.currentFile = entry;
977
+ }
978
+ /**
979
+ * Write a chunk of data to the current file entry.
980
+ * @param data - The data chunk to write.
981
+ */
982
+ writeChunk(data) {
983
+ if (!this.currentFile) {
984
+ throw new Error("No file started. Call startFile() first.");
576
985
  }
577
- if (encrypt && !caps.e2ee) {
578
- throw new DropgateValidationError(
579
- "End-to-end encryption is not supported on this server."
580
- );
986
+ this.currentFile.push(data, false);
987
+ }
988
+ /**
989
+ * End the current file entry.
990
+ */
991
+ endFile() {
992
+ if (!this.currentFile) {
993
+ throw new Error("No file to end.");
581
994
  }
582
- return true;
995
+ this.currentFile.push(new Uint8Array(0), true);
996
+ this.currentFile = null;
583
997
  }
584
998
  /**
585
- * Upload a file to the server with optional encryption.
586
- * @param opts - Upload options including file, server target, and settings.
587
- * @returns Upload result containing the download URL and file identifiers.
588
- * @throws {DropgateValidationError} If input validation fails.
589
- * @throws {DropgateNetworkError} If the server cannot be reached.
590
- * @throws {DropgateProtocolError} If the server returns an error.
591
- * @throws {DropgateAbortError} If the upload is cancelled.
999
+ * Finalize the ZIP archive. Must be called after all files are written.
1000
+ * Waits for all pending async writes to complete before resolving.
592
1001
  */
593
- async uploadFile(opts) {
594
- const {
595
- host,
596
- port,
597
- secure,
598
- file,
599
- lifetimeMs,
600
- encrypt,
601
- maxDownloads,
602
- filenameOverride,
603
- onProgress,
604
- onCancel,
605
- signal,
606
- timeouts = {},
607
- retry = {}
608
- } = opts;
609
- const internalController = signal ? null : new AbortController();
610
- const effectiveSignal = signal || internalController?.signal;
611
- let uploadState = "initializing";
612
- let currentUploadId = null;
613
- let currentBaseUrl = null;
614
- const uploadPromise = (async () => {
615
- try {
616
- const progress = (evt) => {
617
- try {
618
- if (onProgress) onProgress(evt);
619
- } catch {
620
- }
621
- };
622
- if (!this.cryptoObj?.subtle) {
623
- throw new DropgateValidationError(
624
- "Web Crypto API not available (crypto.subtle)."
625
- );
626
- }
627
- const fileSizeBytes = file.size;
628
- progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
629
- const compat = await this.checkCompatibility({
630
- host,
631
- port,
632
- secure,
633
- timeoutMs: timeouts.serverInfoMs ?? 5e3,
634
- signal: effectiveSignal
635
- });
636
- const { baseUrl, serverInfo } = compat;
637
- progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
638
- if (!compat.compatible) {
639
- throw new DropgateValidationError(compat.message);
640
- }
641
- const filename = filenameOverride ?? file.name ?? "file";
642
- const serverSupportsE2EE = Boolean(serverInfo?.capabilities?.upload?.e2ee);
643
- const effectiveEncrypt = encrypt ?? serverSupportsE2EE;
644
- if (!effectiveEncrypt) {
645
- validatePlainFilename(filename);
646
- }
647
- this.validateUploadInputs({ file, lifetimeMs, encrypt: effectiveEncrypt, serverInfo });
648
- let cryptoKey = null;
649
- let keyB64 = null;
650
- let transmittedFilename = filename;
651
- if (effectiveEncrypt) {
652
- progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
653
- try {
654
- cryptoKey = await generateAesGcmKey(this.cryptoObj);
655
- keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
656
- transmittedFilename = await encryptFilenameToBase64(
657
- this.cryptoObj,
658
- filename,
659
- cryptoKey
660
- );
661
- } catch (err) {
662
- throw new DropgateError("Failed to prepare encryption.", {
663
- code: "CRYPTO_PREP_FAILED",
664
- cause: err
665
- });
666
- }
667
- }
668
- const totalChunks = Math.ceil(file.size / this.chunkSize);
669
- const totalUploadSize = estimateTotalUploadSizeBytes(
670
- file.size,
671
- totalChunks,
672
- effectiveEncrypt
673
- );
674
- progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
675
- const initPayload = {
676
- filename: transmittedFilename,
677
- lifetime: lifetimeMs,
678
- isEncrypted: effectiveEncrypt,
679
- totalSize: totalUploadSize,
680
- totalChunks,
681
- ...maxDownloads !== void 0 ? { maxDownloads } : {}
682
- };
683
- const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
684
- method: "POST",
685
- timeoutMs: timeouts.initMs ?? 15e3,
686
- signal: effectiveSignal,
687
- headers: {
688
- "Content-Type": "application/json",
689
- Accept: "application/json"
690
- },
691
- body: JSON.stringify(initPayload)
1002
+ async finalize() {
1003
+ if (this.currentFile) {
1004
+ throw new Error("Cannot finalize with an open file. Call endFile() first.");
1005
+ }
1006
+ if (this.finalized) return;
1007
+ this.finalized = true;
1008
+ this.zip.end();
1009
+ await this.pendingWrites;
1010
+ }
1011
+ };
1012
+
1013
+ // src/p2p/utils.ts
1014
+ function isLocalhostHostname(hostname) {
1015
+ const host = String(hostname || "").toLowerCase();
1016
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
1017
+ }
1018
+ function isSecureContextForP2P(hostname, isSecureContext) {
1019
+ return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
1020
+ }
1021
+ function generateP2PCode(cryptoObj) {
1022
+ const crypto2 = cryptoObj || getDefaultCrypto();
1023
+ const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
1024
+ if (crypto2) {
1025
+ const randomBytes = new Uint8Array(8);
1026
+ crypto2.getRandomValues(randomBytes);
1027
+ let letterPart = "";
1028
+ for (let i = 0; i < 4; i++) {
1029
+ letterPart += letters[randomBytes[i] % letters.length];
1030
+ }
1031
+ let numberPart = "";
1032
+ for (let i = 4; i < 8; i++) {
1033
+ numberPart += (randomBytes[i] % 10).toString();
1034
+ }
1035
+ return `${letterPart}-${numberPart}`;
1036
+ }
1037
+ let a = "";
1038
+ for (let i = 0; i < 4; i++) {
1039
+ a += letters[Math.floor(Math.random() * letters.length)];
1040
+ }
1041
+ let b = "";
1042
+ for (let i = 0; i < 4; i++) {
1043
+ b += Math.floor(Math.random() * 10);
1044
+ }
1045
+ return `${a}-${b}`;
1046
+ }
1047
+ function isP2PCodeLike(code) {
1048
+ return /^[A-Z]{4}-\d{4}$/.test(String(code || "").trim());
1049
+ }
1050
+
1051
+ // src/p2p/helpers.ts
1052
+ function resolvePeerConfig(userConfig, serverCaps) {
1053
+ return {
1054
+ path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
1055
+ iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
1056
+ };
1057
+ }
1058
+ function buildPeerOptions(config = {}) {
1059
+ const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
1060
+ const peerOpts = {
1061
+ host,
1062
+ path: peerjsPath,
1063
+ secure,
1064
+ config: { iceServers },
1065
+ debug: 0
1066
+ };
1067
+ if (port) {
1068
+ peerOpts.port = port;
1069
+ }
1070
+ return peerOpts;
1071
+ }
1072
+ async function createPeerWithRetries(opts) {
1073
+ const { code, codeGenerator, maxAttempts, buildPeer, onCode } = opts;
1074
+ let nextCode = code || codeGenerator();
1075
+ let peer = null;
1076
+ let lastError = null;
1077
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1078
+ onCode?.(nextCode, attempt);
1079
+ try {
1080
+ peer = await new Promise((resolve, reject) => {
1081
+ const instance = buildPeer(nextCode);
1082
+ instance.on("open", () => resolve(instance));
1083
+ instance.on("error", (err2) => {
1084
+ try {
1085
+ instance.destroy();
1086
+ } catch {
1087
+ }
1088
+ reject(err2);
692
1089
  });
693
- if (!initRes.res.ok) {
694
- const errorJson = initRes.json;
695
- const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
696
- throw new DropgateProtocolError(msg, {
697
- details: initRes.json || initRes.text
698
- });
1090
+ });
1091
+ return { peer, code: nextCode };
1092
+ } catch (err2) {
1093
+ lastError = err2;
1094
+ nextCode = codeGenerator();
1095
+ }
1096
+ }
1097
+ throw lastError || new DropgateNetworkError("Could not establish PeerJS connection.");
1098
+ }
1099
+
1100
+ // src/p2p/protocol.ts
1101
+ var P2P_PROTOCOL_VERSION = 3;
1102
+ function isP2PMessage(value) {
1103
+ if (!value || typeof value !== "object") return false;
1104
+ const msg = value;
1105
+ return typeof msg.t === "string" && [
1106
+ "hello",
1107
+ "file_list",
1108
+ "meta",
1109
+ "ready",
1110
+ "chunk",
1111
+ "chunk_ack",
1112
+ "file_end",
1113
+ "file_end_ack",
1114
+ "end",
1115
+ "end_ack",
1116
+ "ping",
1117
+ "pong",
1118
+ "error",
1119
+ "cancelled",
1120
+ "resume",
1121
+ "resume_ack"
1122
+ ].includes(msg.t);
1123
+ }
1124
+ var P2P_CHUNK_SIZE = 64 * 1024;
1125
+ var P2P_MAX_UNACKED_CHUNKS = 32;
1126
+ var P2P_END_ACK_TIMEOUT_MS = 15e3;
1127
+ var P2P_END_ACK_RETRIES = 3;
1128
+ var P2P_END_ACK_RETRY_DELAY_MS = 100;
1129
+ var P2P_CLOSE_GRACE_PERIOD_MS = 2e3;
1130
+
1131
+ // src/p2p/send.ts
1132
+ function generateSessionId() {
1133
+ return crypto.randomUUID();
1134
+ }
1135
+ var ALLOWED_TRANSITIONS = {
1136
+ initializing: ["listening", "closed"],
1137
+ listening: ["handshaking", "closed", "cancelled"],
1138
+ handshaking: ["negotiating", "closed", "cancelled"],
1139
+ negotiating: ["transferring", "closed", "cancelled"],
1140
+ transferring: ["finishing", "closed", "cancelled"],
1141
+ finishing: ["awaiting_ack", "closed", "cancelled"],
1142
+ awaiting_ack: ["completed", "closed", "cancelled"],
1143
+ completed: ["closed"],
1144
+ cancelled: ["closed"],
1145
+ closed: []
1146
+ };
1147
+ async function startP2PSend(opts) {
1148
+ const {
1149
+ file,
1150
+ Peer,
1151
+ serverInfo,
1152
+ host,
1153
+ port,
1154
+ peerjsPath,
1155
+ secure = false,
1156
+ iceServers,
1157
+ codeGenerator,
1158
+ cryptoObj,
1159
+ maxAttempts = 4,
1160
+ chunkSize = P2P_CHUNK_SIZE,
1161
+ endAckTimeoutMs = P2P_END_ACK_TIMEOUT_MS,
1162
+ bufferHighWaterMark = 8 * 1024 * 1024,
1163
+ bufferLowWaterMark = 2 * 1024 * 1024,
1164
+ heartbeatIntervalMs = 5e3,
1165
+ chunkAcknowledgments = true,
1166
+ maxUnackedChunks = P2P_MAX_UNACKED_CHUNKS,
1167
+ onCode,
1168
+ onStatus,
1169
+ onProgress,
1170
+ onComplete,
1171
+ onError,
1172
+ onDisconnect,
1173
+ onCancel,
1174
+ onConnectionHealth
1175
+ } = opts;
1176
+ const files = Array.isArray(file) ? file : [file];
1177
+ const isMultiFile = files.length > 1;
1178
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
1179
+ if (!files.length) {
1180
+ throw new DropgateValidationError("At least one file is required.");
1181
+ }
1182
+ if (!Peer) {
1183
+ throw new DropgateValidationError(
1184
+ "PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
1185
+ );
1186
+ }
1187
+ const p2pCaps = serverInfo?.capabilities?.p2p;
1188
+ if (serverInfo && !p2pCaps?.enabled) {
1189
+ throw new DropgateValidationError("Direct transfer is disabled on this server.");
1190
+ }
1191
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1192
+ { peerjsPath, iceServers },
1193
+ p2pCaps
1194
+ );
1195
+ const peerOpts = buildPeerOptions({
1196
+ host,
1197
+ port,
1198
+ peerjsPath: finalPath,
1199
+ secure,
1200
+ iceServers: finalIceServers
1201
+ });
1202
+ const finalCodeGenerator = codeGenerator || (() => generateP2PCode(cryptoObj));
1203
+ const buildPeer = (id) => new Peer(id, peerOpts);
1204
+ const { peer, code } = await createPeerWithRetries({
1205
+ code: null,
1206
+ codeGenerator: finalCodeGenerator,
1207
+ maxAttempts,
1208
+ buildPeer,
1209
+ onCode
1210
+ });
1211
+ const sessionId = generateSessionId();
1212
+ let state = "listening";
1213
+ let activeConn = null;
1214
+ let sentBytes = 0;
1215
+ let heartbeatTimer = null;
1216
+ let healthCheckTimer = null;
1217
+ let lastActivityTime = Date.now();
1218
+ const unackedChunks = /* @__PURE__ */ new Map();
1219
+ let nextSeq = 0;
1220
+ let ackResolvers = [];
1221
+ const transitionTo = (newState) => {
1222
+ if (!ALLOWED_TRANSITIONS[state].includes(newState)) {
1223
+ console.warn(`[P2P Send] Invalid state transition: ${state} -> ${newState}`);
1224
+ return false;
1225
+ }
1226
+ state = newState;
1227
+ return true;
1228
+ };
1229
+ const reportProgress = (data) => {
1230
+ if (isStopped()) return;
1231
+ const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : totalSize;
1232
+ const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
1233
+ const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
1234
+ onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1235
+ };
1236
+ const safeError = (err2) => {
1237
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1238
+ transitionTo("closed");
1239
+ onError?.(err2);
1240
+ cleanup();
1241
+ };
1242
+ const safeComplete = () => {
1243
+ if (state !== "awaiting_ack" && state !== "finishing") return;
1244
+ transitionTo("completed");
1245
+ onComplete?.();
1246
+ cleanup();
1247
+ };
1248
+ const cleanup = () => {
1249
+ if (heartbeatTimer) {
1250
+ clearInterval(heartbeatTimer);
1251
+ heartbeatTimer = null;
1252
+ }
1253
+ if (healthCheckTimer) {
1254
+ clearInterval(healthCheckTimer);
1255
+ healthCheckTimer = null;
1256
+ }
1257
+ ackResolvers.forEach((resolve) => resolve());
1258
+ ackResolvers = [];
1259
+ unackedChunks.clear();
1260
+ if (typeof window !== "undefined") {
1261
+ window.removeEventListener("beforeunload", handleUnload);
1262
+ }
1263
+ try {
1264
+ activeConn?.close();
1265
+ } catch {
1266
+ }
1267
+ try {
1268
+ peer.destroy();
1269
+ } catch {
1270
+ }
1271
+ };
1272
+ const handleUnload = () => {
1273
+ try {
1274
+ activeConn?.send({ t: "error", message: "Sender closed the connection." });
1275
+ } catch {
1276
+ }
1277
+ stop();
1278
+ };
1279
+ if (typeof window !== "undefined") {
1280
+ window.addEventListener("beforeunload", handleUnload);
1281
+ }
1282
+ const stop = () => {
1283
+ if (state === "closed" || state === "cancelled") return;
1284
+ if (state === "completed") {
1285
+ cleanup();
1286
+ return;
1287
+ }
1288
+ const wasActive = state === "transferring" || state === "finishing" || state === "awaiting_ack";
1289
+ transitionTo("cancelled");
1290
+ try {
1291
+ if (activeConn && activeConn.open) {
1292
+ activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
1293
+ }
1294
+ } catch {
1295
+ }
1296
+ if (wasActive && onCancel) {
1297
+ onCancel({ cancelledBy: "sender" });
1298
+ }
1299
+ cleanup();
1300
+ };
1301
+ const isStopped = () => state === "closed" || state === "cancelled";
1302
+ const startHealthMonitoring = (conn) => {
1303
+ if (!onConnectionHealth) return;
1304
+ healthCheckTimer = setInterval(() => {
1305
+ if (isStopped()) return;
1306
+ const dc = conn._dc;
1307
+ if (!dc) return;
1308
+ const health = {
1309
+ iceConnectionState: dc.readyState === "open" ? "connected" : "disconnected",
1310
+ bufferedAmount: dc.bufferedAmount,
1311
+ lastActivityMs: Date.now() - lastActivityTime
1312
+ };
1313
+ onConnectionHealth(health);
1314
+ }, 2e3);
1315
+ };
1316
+ const handleChunkAck = (msg) => {
1317
+ lastActivityTime = Date.now();
1318
+ unackedChunks.delete(msg.seq);
1319
+ reportProgress({ received: msg.received, total: totalSize });
1320
+ const resolver = ackResolvers.shift();
1321
+ if (resolver) resolver();
1322
+ };
1323
+ const waitForAck = () => {
1324
+ return new Promise((resolve) => {
1325
+ ackResolvers.push(resolve);
1326
+ });
1327
+ };
1328
+ const sendChunk = async (conn, data, offset, fileTotal) => {
1329
+ if (chunkAcknowledgments) {
1330
+ while (unackedChunks.size >= maxUnackedChunks) {
1331
+ await Promise.race([
1332
+ waitForAck(),
1333
+ sleep(1e3)
1334
+ // Timeout to prevent deadlock
1335
+ ]);
1336
+ if (isStopped()) return;
1337
+ }
1338
+ }
1339
+ const seq = nextSeq++;
1340
+ if (chunkAcknowledgments) {
1341
+ unackedChunks.set(seq, { offset, size: data.byteLength, sentAt: Date.now() });
1342
+ }
1343
+ conn.send({ t: "chunk", seq, offset, size: data.byteLength, total: fileTotal ?? totalSize });
1344
+ conn.send(data);
1345
+ sentBytes += data.byteLength;
1346
+ const dc = conn._dc;
1347
+ if (dc && bufferHighWaterMark > 0) {
1348
+ while (dc.bufferedAmount > bufferHighWaterMark) {
1349
+ await new Promise((resolve) => {
1350
+ const fallback = setTimeout(resolve, 60);
1351
+ try {
1352
+ dc.addEventListener(
1353
+ "bufferedamountlow",
1354
+ () => {
1355
+ clearTimeout(fallback);
1356
+ resolve();
1357
+ },
1358
+ { once: true }
1359
+ );
1360
+ } catch {
1361
+ }
1362
+ });
1363
+ if (isStopped()) return;
1364
+ }
1365
+ }
1366
+ };
1367
+ const waitForEndAck = async (conn, ackPromise) => {
1368
+ const baseTimeout = endAckTimeoutMs;
1369
+ for (let attempt = 0; attempt < P2P_END_ACK_RETRIES; attempt++) {
1370
+ conn.send({ t: "end", attempt });
1371
+ const timeout = baseTimeout * Math.pow(1.5, attempt);
1372
+ const result = await Promise.race([
1373
+ ackPromise,
1374
+ sleep(timeout).then(() => null)
1375
+ ]);
1376
+ if (result && result.t === "end_ack") {
1377
+ return result;
1378
+ }
1379
+ if (isStopped()) {
1380
+ throw new DropgateNetworkError("Connection closed during completion.");
1381
+ }
1382
+ }
1383
+ throw new DropgateNetworkError("Receiver did not confirm completion after retries.");
1384
+ };
1385
+ peer.on("connection", (conn) => {
1386
+ if (isStopped()) return;
1387
+ if (activeConn) {
1388
+ const isOldConnOpen = activeConn.open !== false;
1389
+ if (isOldConnOpen && state === "transferring") {
1390
+ try {
1391
+ conn.send({ t: "error", message: "Transfer already in progress." });
1392
+ } catch {
1393
+ }
1394
+ try {
1395
+ conn.close();
1396
+ } catch {
1397
+ }
1398
+ return;
1399
+ } else if (!isOldConnOpen) {
1400
+ try {
1401
+ activeConn.close();
1402
+ } catch {
1403
+ }
1404
+ activeConn = null;
1405
+ state = "listening";
1406
+ sentBytes = 0;
1407
+ nextSeq = 0;
1408
+ unackedChunks.clear();
1409
+ } else {
1410
+ try {
1411
+ conn.send({ t: "error", message: "Another receiver is already connected." });
1412
+ } catch {
1413
+ }
1414
+ try {
1415
+ conn.close();
1416
+ } catch {
699
1417
  }
700
- const initJson = initRes.json;
701
- const uploadId = initJson?.uploadId;
702
- if (!uploadId || typeof uploadId !== "string") {
703
- throw new DropgateProtocolError(
704
- "Server did not return a valid uploadId."
1418
+ return;
1419
+ }
1420
+ }
1421
+ activeConn = conn;
1422
+ transitionTo("handshaking");
1423
+ if (!isStopped()) onStatus?.({ phase: "connected", message: "Receiver connected." });
1424
+ lastActivityTime = Date.now();
1425
+ let helloResolve = null;
1426
+ let readyResolve = null;
1427
+ let endAckResolve = null;
1428
+ let fileEndAckResolve = null;
1429
+ const helloPromise = new Promise((resolve) => {
1430
+ helloResolve = resolve;
1431
+ });
1432
+ const readyPromise = new Promise((resolve) => {
1433
+ readyResolve = resolve;
1434
+ });
1435
+ const endAckPromise = new Promise((resolve) => {
1436
+ endAckResolve = resolve;
1437
+ });
1438
+ conn.on("data", (data) => {
1439
+ lastActivityTime = Date.now();
1440
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
1441
+ return;
1442
+ }
1443
+ if (!isP2PMessage(data)) return;
1444
+ const msg = data;
1445
+ switch (msg.t) {
1446
+ case "hello":
1447
+ helloResolve?.(msg.protocolVersion);
1448
+ break;
1449
+ case "ready":
1450
+ if (!isStopped()) onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
1451
+ readyResolve?.();
1452
+ break;
1453
+ case "chunk_ack":
1454
+ handleChunkAck(msg);
1455
+ break;
1456
+ case "file_end_ack":
1457
+ fileEndAckResolve?.(msg);
1458
+ break;
1459
+ case "end_ack":
1460
+ endAckResolve?.(msg);
1461
+ break;
1462
+ case "pong":
1463
+ break;
1464
+ case "error":
1465
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1466
+ break;
1467
+ case "cancelled":
1468
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1469
+ transitionTo("cancelled");
1470
+ onCancel?.({ cancelledBy: "receiver", message: msg.reason });
1471
+ cleanup();
1472
+ break;
1473
+ }
1474
+ });
1475
+ conn.on("open", async () => {
1476
+ try {
1477
+ if (isStopped()) return;
1478
+ startHealthMonitoring(conn);
1479
+ conn.send({
1480
+ t: "hello",
1481
+ protocolVersion: P2P_PROTOCOL_VERSION,
1482
+ sessionId
1483
+ });
1484
+ const receiverVersion = await Promise.race([
1485
+ helloPromise,
1486
+ sleep(1e4).then(() => null)
1487
+ ]);
1488
+ if (isStopped()) return;
1489
+ if (receiverVersion === null) {
1490
+ throw new DropgateNetworkError("Receiver did not respond to handshake.");
1491
+ } else if (receiverVersion !== P2P_PROTOCOL_VERSION) {
1492
+ throw new DropgateNetworkError(
1493
+ `Protocol version mismatch: sender v${P2P_PROTOCOL_VERSION}, receiver v${receiverVersion}`
705
1494
  );
706
1495
  }
707
- currentUploadId = uploadId;
708
- currentBaseUrl = baseUrl;
709
- uploadState = "uploading";
710
- const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
711
- const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
712
- const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
713
- for (let i = 0; i < totalChunks; i++) {
714
- if (effectiveSignal?.aborted) {
715
- throw effectiveSignal.reason || new DropgateAbortError();
716
- }
717
- const start = i * this.chunkSize;
718
- const end = Math.min(start + this.chunkSize, file.size);
719
- let chunkBlob = file.slice(start, end);
720
- const percentComplete = i / totalChunks * 100;
721
- const processedBytes = i * this.chunkSize;
722
- progress({
723
- phase: "chunk",
724
- text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
725
- percent: percentComplete,
726
- processedBytes,
727
- totalBytes: fileSizeBytes,
728
- chunkIndex: i,
729
- totalChunks
1496
+ transitionTo("negotiating");
1497
+ if (!isStopped()) onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
1498
+ if (isMultiFile) {
1499
+ conn.send({
1500
+ t: "file_list",
1501
+ fileCount: files.length,
1502
+ files: files.map((f) => ({ name: f.name, size: f.size, mime: f.type || "application/octet-stream" })),
1503
+ totalSize
730
1504
  });
731
- const chunkBuffer = await chunkBlob.arrayBuffer();
732
- let uploadBlob;
733
- if (effectiveEncrypt && cryptoKey) {
734
- uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
735
- } else {
736
- uploadBlob = new Blob([chunkBuffer]);
737
- }
738
- if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
739
- throw new DropgateValidationError(
740
- "Chunk too large (client-side). Check chunk size settings."
741
- );
742
- }
743
- const toHash = await uploadBlob.arrayBuffer();
744
- const hashHex = await sha256Hex(this.cryptoObj, toHash);
745
- const headers = {
746
- "Content-Type": "application/octet-stream",
747
- "X-Upload-ID": uploadId,
748
- "X-Chunk-Index": String(i),
749
- "X-Chunk-Hash": hashHex
750
- };
751
- const chunkUrl = `${baseUrl}/upload/chunk`;
752
- await this.attemptChunkUpload(
753
- chunkUrl,
754
- {
755
- method: "POST",
756
- headers,
757
- body: uploadBlob
758
- },
759
- {
760
- retries,
761
- backoffMs: baseBackoffMs,
762
- maxBackoffMs,
763
- timeoutMs: timeouts.chunkMs ?? 6e4,
764
- signal: effectiveSignal,
765
- progress,
766
- chunkIndex: i,
767
- totalChunks,
768
- chunkSize: this.chunkSize,
769
- fileSizeBytes
770
- }
771
- );
772
1505
  }
773
- progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
774
- uploadState = "completing";
775
- const completeRes = await fetchJson(
776
- this.fetchFn,
777
- `${baseUrl}/upload/complete`,
778
- {
779
- method: "POST",
780
- timeoutMs: timeouts.completeMs ?? 3e4,
781
- signal: effectiveSignal,
782
- headers: {
783
- "Content-Type": "application/json",
784
- Accept: "application/json"
785
- },
786
- body: JSON.stringify({ uploadId })
1506
+ conn.send({
1507
+ t: "meta",
1508
+ sessionId,
1509
+ name: files[0].name,
1510
+ size: files[0].size,
1511
+ mime: files[0].type || "application/octet-stream",
1512
+ ...isMultiFile ? { fileIndex: 0 } : {}
1513
+ });
1514
+ const dc = conn._dc;
1515
+ if (dc && Number.isFinite(bufferLowWaterMark)) {
1516
+ try {
1517
+ dc.bufferedAmountLowThreshold = bufferLowWaterMark;
1518
+ } catch {
787
1519
  }
788
- );
789
- if (!completeRes.res.ok) {
790
- const errorJson = completeRes.json;
791
- const msg = errorJson?.error || "Finalisation failed.";
792
- throw new DropgateProtocolError(msg, {
793
- details: completeRes.json || completeRes.text
794
- });
795
1520
  }
796
- const completeJson = completeRes.json;
797
- const fileId = completeJson?.id;
798
- if (!fileId || typeof fileId !== "string") {
799
- throw new DropgateProtocolError(
800
- "Server did not return a valid file id."
801
- );
1521
+ await readyPromise;
1522
+ if (isStopped()) return;
1523
+ if (heartbeatIntervalMs > 0) {
1524
+ heartbeatTimer = setInterval(() => {
1525
+ if (state === "transferring" || state === "finishing" || state === "awaiting_ack") {
1526
+ try {
1527
+ conn.send({ t: "ping", timestamp: Date.now() });
1528
+ } catch {
1529
+ }
1530
+ }
1531
+ }, heartbeatIntervalMs);
802
1532
  }
803
- let downloadUrl = `${baseUrl}/${fileId}`;
804
- if (effectiveEncrypt && keyB64) {
805
- downloadUrl += `#${keyB64}`;
1533
+ transitionTo("transferring");
1534
+ let overallSentBytes = 0;
1535
+ for (let fi = 0; fi < files.length; fi++) {
1536
+ const currentFile = files[fi];
1537
+ if (isMultiFile && fi > 0) {
1538
+ conn.send({
1539
+ t: "meta",
1540
+ sessionId,
1541
+ name: currentFile.name,
1542
+ size: currentFile.size,
1543
+ mime: currentFile.type || "application/octet-stream",
1544
+ fileIndex: fi
1545
+ });
1546
+ }
1547
+ for (let offset = 0; offset < currentFile.size; offset += chunkSize) {
1548
+ if (isStopped()) return;
1549
+ const slice = currentFile.slice(offset, offset + chunkSize);
1550
+ const buf = await slice.arrayBuffer();
1551
+ if (isStopped()) return;
1552
+ await sendChunk(conn, buf, offset, currentFile.size);
1553
+ overallSentBytes += buf.byteLength;
1554
+ reportProgress({ received: overallSentBytes, total: totalSize });
1555
+ }
1556
+ if (isStopped()) return;
1557
+ if (isMultiFile) {
1558
+ const fileEndAckPromise = new Promise((resolve) => {
1559
+ fileEndAckResolve = resolve;
1560
+ });
1561
+ conn.send({ t: "file_end", fileIndex: fi });
1562
+ const feAck = await Promise.race([
1563
+ fileEndAckPromise,
1564
+ sleep(endAckTimeoutMs).then(() => null)
1565
+ ]);
1566
+ if (isStopped()) return;
1567
+ if (!feAck) {
1568
+ throw new DropgateNetworkError(`Receiver did not confirm receipt of file ${fi + 1}/${files.length}.`);
1569
+ }
1570
+ }
806
1571
  }
807
- progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
808
- uploadState = "completed";
809
- return {
810
- downloadUrl,
811
- fileId,
812
- uploadId,
813
- baseUrl,
814
- ...effectiveEncrypt && keyB64 ? { keyB64 } : {}
815
- };
816
- } catch (err) {
817
- if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
818
- uploadState = "cancelled";
819
- onCancel?.();
820
- } else {
821
- uploadState = "error";
1572
+ if (isStopped()) return;
1573
+ transitionTo("finishing");
1574
+ transitionTo("awaiting_ack");
1575
+ const ackResult = await waitForEndAck(conn, endAckPromise);
1576
+ if (isStopped()) return;
1577
+ const ackTotal = Number(ackResult.total) || totalSize;
1578
+ const ackReceived = Number(ackResult.received) || 0;
1579
+ if (ackTotal && ackReceived < ackTotal) {
1580
+ throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
822
1581
  }
823
- throw err;
1582
+ reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
1583
+ safeComplete();
1584
+ } catch (err2) {
1585
+ safeError(err2);
824
1586
  }
825
- })();
826
- const callCancelEndpoint = async (uploadId, baseUrl) => {
827
- try {
828
- await fetchJson(this.fetchFn, `${baseUrl}/upload/cancel`, {
829
- method: "POST",
830
- timeoutMs: 5e3,
831
- headers: {
832
- "Content-Type": "application/json",
833
- Accept: "application/json"
834
- },
835
- body: JSON.stringify({ uploadId })
836
- });
837
- } catch {
1587
+ });
1588
+ conn.on("error", (err2) => {
1589
+ safeError(err2);
1590
+ });
1591
+ conn.on("close", () => {
1592
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1593
+ cleanup();
1594
+ return;
1595
+ }
1596
+ if (state === "awaiting_ack") {
1597
+ setTimeout(() => {
1598
+ if (state === "awaiting_ack") {
1599
+ safeError(new DropgateNetworkError("Connection closed while awaiting confirmation."));
1600
+ }
1601
+ }, P2P_CLOSE_GRACE_PERIOD_MS);
1602
+ return;
1603
+ }
1604
+ if (state === "transferring" || state === "finishing") {
1605
+ transitionTo("cancelled");
1606
+ onCancel?.({ cancelledBy: "receiver" });
1607
+ cleanup();
1608
+ } else {
1609
+ activeConn = null;
1610
+ state = "listening";
1611
+ sentBytes = 0;
1612
+ nextSeq = 0;
1613
+ unackedChunks.clear();
1614
+ onDisconnect?.();
838
1615
  }
839
- };
840
- return {
841
- result: uploadPromise,
842
- cancel: (reason) => {
843
- if (uploadState === "completed" || uploadState === "cancelled") return;
844
- uploadState = "cancelled";
845
- if (currentUploadId && currentBaseUrl) {
846
- callCancelEndpoint(currentUploadId, currentBaseUrl).catch(() => {
847
- });
848
- }
849
- internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
850
- },
851
- getStatus: () => uploadState
852
- };
1616
+ });
1617
+ });
1618
+ return {
1619
+ peer,
1620
+ code,
1621
+ sessionId,
1622
+ stop,
1623
+ getStatus: () => state,
1624
+ getBytesSent: () => sentBytes,
1625
+ getConnectedPeerId: () => {
1626
+ if (!activeConn) return null;
1627
+ return activeConn.peer || null;
1628
+ }
1629
+ };
1630
+ }
1631
+
1632
+ // src/p2p/receive.ts
1633
+ var ALLOWED_TRANSITIONS2 = {
1634
+ initializing: ["connecting", "closed"],
1635
+ connecting: ["handshaking", "closed", "cancelled"],
1636
+ handshaking: ["negotiating", "closed", "cancelled"],
1637
+ negotiating: ["transferring", "closed", "cancelled"],
1638
+ transferring: ["completed", "closed", "cancelled"],
1639
+ completed: ["closed"],
1640
+ cancelled: ["closed"],
1641
+ closed: []
1642
+ };
1643
+ async function startP2PReceive(opts) {
1644
+ const {
1645
+ code,
1646
+ Peer,
1647
+ serverInfo,
1648
+ host,
1649
+ port,
1650
+ peerjsPath,
1651
+ secure = false,
1652
+ iceServers,
1653
+ autoReady = true,
1654
+ watchdogTimeoutMs = 15e3,
1655
+ onStatus,
1656
+ onMeta,
1657
+ onData,
1658
+ onProgress,
1659
+ onFileStart,
1660
+ onFileEnd,
1661
+ onComplete,
1662
+ onError,
1663
+ onDisconnect,
1664
+ onCancel
1665
+ } = opts;
1666
+ if (!code) {
1667
+ throw new DropgateValidationError("No sharing code was provided.");
853
1668
  }
854
- /**
855
- * Download a file from the server with optional decryption.
856
- *
857
- * **Important:** For large files, you must provide an `onData` callback to stream
858
- * data incrementally. Without it, the entire file is buffered in memory, which will
859
- * cause memory exhaustion for large files. Files exceeding 100MB without an `onData`
860
- * callback will throw a validation error.
861
- *
862
- * @param opts - Download options including file ID, server target, and optional key.
863
- * @param opts.onData - Streaming callback that receives data chunks. Required for files > 100MB.
864
- * @returns Download result containing filename and received bytes.
865
- * @throws {DropgateValidationError} If input validation fails or file is too large without onData.
866
- * @throws {DropgateNetworkError} If the server cannot be reached.
867
- * @throws {DropgateProtocolError} If the server returns an error.
868
- * @throws {DropgateAbortError} If the download is cancelled.
869
- */
870
- async downloadFile(opts) {
871
- const {
872
- host,
873
- port,
874
- secure,
875
- fileId,
876
- keyB64,
877
- onProgress,
878
- onData,
879
- signal,
880
- timeoutMs = 6e4
881
- } = opts;
882
- const progress = (evt) => {
883
- try {
884
- if (onProgress) onProgress(evt);
885
- } catch {
1669
+ if (!Peer) {
1670
+ throw new DropgateValidationError(
1671
+ "PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
1672
+ );
1673
+ }
1674
+ const p2pCaps = serverInfo?.capabilities?.p2p;
1675
+ if (serverInfo && !p2pCaps?.enabled) {
1676
+ throw new DropgateValidationError("Direct transfer is disabled on this server.");
1677
+ }
1678
+ const normalizedCode = String(code).trim().replace(/\s+/g, "").toUpperCase();
1679
+ if (!isP2PCodeLike(normalizedCode)) {
1680
+ throw new DropgateValidationError("Invalid direct transfer code.");
1681
+ }
1682
+ const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1683
+ { peerjsPath, iceServers },
1684
+ p2pCaps
1685
+ );
1686
+ const peerOpts = buildPeerOptions({
1687
+ host,
1688
+ port,
1689
+ peerjsPath: finalPath,
1690
+ secure,
1691
+ iceServers: finalIceServers
1692
+ });
1693
+ const peer = new Peer(void 0, peerOpts);
1694
+ let state = "initializing";
1695
+ let total = 0;
1696
+ let received = 0;
1697
+ let currentSessionId = null;
1698
+ let writeQueue = Promise.resolve();
1699
+ let watchdogTimer = null;
1700
+ let activeConn = null;
1701
+ let pendingChunk = null;
1702
+ let fileList = null;
1703
+ let currentFileReceived = 0;
1704
+ let totalReceivedAllFiles = 0;
1705
+ const transitionTo = (newState) => {
1706
+ if (!ALLOWED_TRANSITIONS2[state].includes(newState)) {
1707
+ console.warn(`[P2P Receive] Invalid state transition: ${state} -> ${newState}`);
1708
+ return false;
1709
+ }
1710
+ state = newState;
1711
+ return true;
1712
+ };
1713
+ const isStopped = () => state === "closed" || state === "cancelled";
1714
+ const resetWatchdog = () => {
1715
+ if (watchdogTimeoutMs <= 0) return;
1716
+ if (watchdogTimer) {
1717
+ clearTimeout(watchdogTimer);
1718
+ }
1719
+ watchdogTimer = setTimeout(() => {
1720
+ if (state === "transferring") {
1721
+ safeError(new DropgateNetworkError("Connection timed out (no data received)."));
886
1722
  }
887
- };
888
- if (!fileId || typeof fileId !== "string") {
889
- throw new DropgateValidationError("File ID is required.");
1723
+ }, watchdogTimeoutMs);
1724
+ };
1725
+ const clearWatchdog = () => {
1726
+ if (watchdogTimer) {
1727
+ clearTimeout(watchdogTimer);
1728
+ watchdogTimer = null;
890
1729
  }
891
- progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
892
- const compat = await this.checkCompatibility({
893
- host,
894
- port,
895
- secure,
896
- timeoutMs,
897
- signal
898
- });
899
- const { baseUrl } = compat;
900
- progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
901
- if (!compat.compatible) {
902
- throw new DropgateValidationError(compat.message);
1730
+ };
1731
+ const safeError = (err2) => {
1732
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1733
+ transitionTo("closed");
1734
+ onError?.(err2);
1735
+ cleanup();
1736
+ };
1737
+ const safeComplete = (completeData) => {
1738
+ if (state !== "transferring") return;
1739
+ transitionTo("completed");
1740
+ onComplete?.(completeData);
1741
+ };
1742
+ const cleanup = () => {
1743
+ clearWatchdog();
1744
+ if (typeof window !== "undefined") {
1745
+ window.removeEventListener("beforeunload", handleUnload);
903
1746
  }
904
- progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
905
- const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
906
- let metadata;
907
1747
  try {
908
- const metaRes = await this.fetchFn(`${baseUrl}/api/file/${fileId}/meta`, {
909
- method: "GET",
910
- headers: { Accept: "application/json" },
911
- signal: metaSignal
912
- });
913
- if (!metaRes.ok) {
914
- if (metaRes.status === 404) {
915
- throw new DropgateProtocolError("File not found or has expired.");
916
- }
917
- throw new DropgateProtocolError(`Failed to fetch file metadata (status ${metaRes.status}).`);
918
- }
919
- metadata = await metaRes.json();
920
- } catch (err) {
921
- if (err instanceof DropgateError) throw err;
922
- if (err instanceof Error && err.name === "AbortError") {
923
- throw new DropgateAbortError("Download cancelled.");
924
- }
925
- throw new DropgateNetworkError("Could not fetch file metadata.", { cause: err });
926
- } finally {
927
- metaCleanup();
1748
+ peer.destroy();
1749
+ } catch {
928
1750
  }
929
- const isEncrypted = Boolean(metadata.isEncrypted);
930
- const totalBytes = metadata.sizeBytes || 0;
931
- if (!onData && totalBytes > MAX_IN_MEMORY_DOWNLOAD_BYTES) {
932
- const sizeMB = Math.round(totalBytes / (1024 * 1024));
933
- const limitMB = Math.round(MAX_IN_MEMORY_DOWNLOAD_BYTES / (1024 * 1024));
934
- throw new DropgateValidationError(
935
- `File is too large (${sizeMB}MB) to download without streaming. Provide an onData callback to stream files larger than ${limitMB}MB.`
936
- );
1751
+ };
1752
+ const handleUnload = () => {
1753
+ try {
1754
+ activeConn?.send({ t: "error", message: "Receiver closed the connection." });
1755
+ } catch {
937
1756
  }
938
- let filename;
939
- let cryptoKey;
940
- if (isEncrypted) {
941
- if (!keyB64) {
942
- throw new DropgateValidationError("Decryption key is required for encrypted files.");
943
- }
944
- if (!this.cryptoObj?.subtle) {
945
- throw new DropgateValidationError("Web Crypto API not available for decryption.");
946
- }
947
- progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
948
- try {
949
- cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
950
- filename = await decryptFilenameFromBase64(
951
- this.cryptoObj,
952
- metadata.encryptedFilename,
953
- cryptoKey,
954
- this.base64
955
- );
956
- } catch (err) {
957
- throw new DropgateError("Failed to decrypt filename. Invalid key or corrupted data.", {
958
- code: "DECRYPT_FILENAME_FAILED",
959
- cause: err
960
- });
1757
+ stop();
1758
+ };
1759
+ if (typeof window !== "undefined") {
1760
+ window.addEventListener("beforeunload", handleUnload);
1761
+ }
1762
+ const stop = () => {
1763
+ if (state === "closed" || state === "cancelled") return;
1764
+ if (state === "completed") {
1765
+ cleanup();
1766
+ return;
1767
+ }
1768
+ const wasActive = state === "transferring";
1769
+ transitionTo("cancelled");
1770
+ try {
1771
+ if (activeConn && activeConn.open) {
1772
+ activeConn.send({ t: "cancelled", reason: "Receiver cancelled the transfer." });
961
1773
  }
962
- } else {
963
- filename = metadata.filename || "file";
1774
+ } catch {
964
1775
  }
965
- progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
966
- const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
967
- let receivedBytes = 0;
968
- const dataChunks = [];
969
- const collectData = !onData;
1776
+ if (wasActive && onCancel) {
1777
+ onCancel({ cancelledBy: "receiver" });
1778
+ }
1779
+ cleanup();
1780
+ };
1781
+ const sendChunkAck = (conn, seq) => {
970
1782
  try {
971
- const downloadRes = await this.fetchFn(`${baseUrl}/api/file/${fileId}`, {
972
- method: "GET",
973
- signal: downloadSignal
1783
+ conn.send({ t: "chunk_ack", seq, received });
1784
+ } catch {
1785
+ }
1786
+ };
1787
+ peer.on("error", (err2) => {
1788
+ safeError(err2);
1789
+ });
1790
+ peer.on("open", () => {
1791
+ transitionTo("connecting");
1792
+ const conn = peer.connect(normalizedCode, { reliable: true });
1793
+ activeConn = conn;
1794
+ conn.on("open", () => {
1795
+ transitionTo("handshaking");
1796
+ onStatus?.({ phase: "connected", message: "Connected." });
1797
+ conn.send({
1798
+ t: "hello",
1799
+ protocolVersion: P2P_PROTOCOL_VERSION,
1800
+ sessionId: ""
974
1801
  });
975
- if (!downloadRes.ok) {
976
- throw new DropgateProtocolError(`Download failed (status ${downloadRes.status}).`);
977
- }
978
- if (!downloadRes.body) {
979
- throw new DropgateProtocolError("Streaming response not available.");
980
- }
981
- const reader = downloadRes.body.getReader();
982
- if (isEncrypted && cryptoKey) {
983
- const ENCRYPTED_CHUNK_SIZE = this.chunkSize + ENCRYPTION_OVERHEAD_PER_CHUNK;
984
- const pendingChunks = [];
985
- let pendingLength = 0;
986
- const flushPending = () => {
987
- if (pendingChunks.length === 0) return new Uint8Array(0);
988
- if (pendingChunks.length === 1) {
989
- const result2 = pendingChunks[0];
990
- pendingChunks.length = 0;
991
- pendingLength = 0;
992
- return result2;
993
- }
994
- const result = new Uint8Array(pendingLength);
995
- let offset = 0;
996
- for (const chunk of pendingChunks) {
997
- result.set(chunk, offset);
998
- offset += chunk.length;
999
- }
1000
- pendingChunks.length = 0;
1001
- pendingLength = 0;
1002
- return result;
1003
- };
1004
- while (true) {
1005
- if (signal?.aborted) {
1006
- throw new DropgateAbortError("Download cancelled.");
1007
- }
1008
- const { done, value } = await reader.read();
1009
- if (done) break;
1010
- pendingChunks.push(value);
1011
- pendingLength += value.length;
1012
- while (pendingLength >= ENCRYPTED_CHUNK_SIZE) {
1013
- const buffer = flushPending();
1014
- const encryptedChunk = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
1015
- if (buffer.length > ENCRYPTED_CHUNK_SIZE) {
1016
- const remainder = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
1017
- pendingChunks.push(remainder);
1018
- pendingLength = remainder.length;
1802
+ });
1803
+ conn.on("data", async (data) => {
1804
+ try {
1805
+ resetWatchdog();
1806
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || typeof Blob !== "undefined" && data instanceof Blob) {
1807
+ let bufPromise;
1808
+ if (data instanceof ArrayBuffer) {
1809
+ bufPromise = Promise.resolve(new Uint8Array(data));
1810
+ } else if (ArrayBuffer.isView(data)) {
1811
+ bufPromise = Promise.resolve(
1812
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
1813
+ );
1814
+ } else if (typeof Blob !== "undefined" && data instanceof Blob) {
1815
+ bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
1816
+ } else {
1817
+ return;
1818
+ }
1819
+ const chunkSeq = pendingChunk?.seq ?? -1;
1820
+ pendingChunk = null;
1821
+ writeQueue = writeQueue.then(async () => {
1822
+ const buf = await bufPromise;
1823
+ if (onData) {
1824
+ await onData(buf);
1019
1825
  }
1020
- const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedChunk, cryptoKey);
1021
- const decryptedData = new Uint8Array(decryptedBuffer);
1022
- if (collectData) {
1023
- dataChunks.push(decryptedData);
1024
- } else {
1025
- await onData(decryptedData);
1826
+ received += buf.byteLength;
1827
+ currentFileReceived += buf.byteLength;
1828
+ const progressReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
1829
+ const progressTotal = fileList ? fileList.totalSize : total;
1830
+ const percent = progressTotal ? Math.min(100, progressReceived / progressTotal * 100) : 0;
1831
+ if (!isStopped()) onProgress?.({ processedBytes: progressReceived, totalBytes: progressTotal, percent });
1832
+ if (chunkSeq >= 0) {
1833
+ sendChunkAck(conn, chunkSeq);
1026
1834
  }
1027
- }
1028
- receivedBytes += value.length;
1029
- const percent = totalBytes > 0 ? Math.round(receivedBytes / totalBytes * 100) : 0;
1030
- progress({
1031
- phase: "decrypting",
1032
- text: `Downloading & decrypting... (${percent}%)`,
1033
- percent,
1034
- processedBytes: receivedBytes,
1035
- totalBytes
1835
+ }).catch((err2) => {
1836
+ try {
1837
+ conn.send({
1838
+ t: "error",
1839
+ message: err2?.message || "Receiver write failed."
1840
+ });
1841
+ } catch {
1842
+ }
1843
+ safeError(err2);
1036
1844
  });
1845
+ return;
1037
1846
  }
1038
- if (pendingLength > 0) {
1039
- const buffer = flushPending();
1040
- const decryptedBuffer = await decryptChunk(this.cryptoObj, buffer, cryptoKey);
1041
- const decryptedData = new Uint8Array(decryptedBuffer);
1042
- if (collectData) {
1043
- dataChunks.push(decryptedData);
1044
- } else {
1045
- await onData(decryptedData);
1046
- }
1047
- }
1048
- } else {
1049
- while (true) {
1050
- if (signal?.aborted) {
1051
- throw new DropgateAbortError("Download cancelled.");
1847
+ if (!isP2PMessage(data)) return;
1848
+ const msg = data;
1849
+ switch (msg.t) {
1850
+ case "hello":
1851
+ currentSessionId = msg.sessionId || null;
1852
+ transitionTo("negotiating");
1853
+ onStatus?.({ phase: "waiting", message: "Waiting for file details..." });
1854
+ break;
1855
+ case "file_list":
1856
+ fileList = msg;
1857
+ total = fileList.totalSize;
1858
+ break;
1859
+ case "meta": {
1860
+ if (state !== "negotiating" && !(state === "transferring" && fileList)) {
1861
+ return;
1862
+ }
1863
+ if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
1864
+ try {
1865
+ conn.send({ t: "error", message: "Busy with another session." });
1866
+ } catch {
1867
+ }
1868
+ return;
1869
+ }
1870
+ if (msg.sessionId) {
1871
+ currentSessionId = msg.sessionId;
1872
+ }
1873
+ const name = String(msg.name || "file");
1874
+ const fileSize = Number(msg.size) || 0;
1875
+ const fi = msg.fileIndex;
1876
+ if (fileList && typeof fi === "number" && fi > 0) {
1877
+ currentFileReceived = 0;
1878
+ onFileStart?.({ fileIndex: fi, name, size: fileSize });
1879
+ break;
1880
+ }
1881
+ received = 0;
1882
+ currentFileReceived = 0;
1883
+ totalReceivedAllFiles = 0;
1884
+ if (!fileList) {
1885
+ total = fileSize;
1886
+ }
1887
+ writeQueue = Promise.resolve();
1888
+ const sendReady = () => {
1889
+ transitionTo("transferring");
1890
+ resetWatchdog();
1891
+ if (fileList) {
1892
+ onFileStart?.({ fileIndex: 0, name, size: fileSize });
1893
+ }
1894
+ try {
1895
+ conn.send({ t: "ready" });
1896
+ } catch {
1897
+ }
1898
+ };
1899
+ const metaEvt = { name, total };
1900
+ if (fileList) {
1901
+ metaEvt.fileCount = fileList.fileCount;
1902
+ metaEvt.files = fileList.files.map((f) => ({ name: f.name, size: f.size }));
1903
+ metaEvt.totalSize = fileList.totalSize;
1904
+ }
1905
+ if (autoReady) {
1906
+ if (!isStopped()) {
1907
+ onMeta?.(metaEvt);
1908
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1909
+ }
1910
+ sendReady();
1911
+ } else {
1912
+ metaEvt.sendReady = sendReady;
1913
+ if (!isStopped()) {
1914
+ onMeta?.(metaEvt);
1915
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1916
+ }
1917
+ }
1918
+ break;
1052
1919
  }
1053
- const { done, value } = await reader.read();
1054
- if (done) break;
1055
- if (collectData) {
1056
- dataChunks.push(value);
1057
- } else {
1058
- await onData(value);
1920
+ case "chunk":
1921
+ pendingChunk = msg;
1922
+ break;
1923
+ case "ping":
1924
+ try {
1925
+ conn.send({ t: "pong", timestamp: Date.now() });
1926
+ } catch {
1927
+ }
1928
+ break;
1929
+ case "file_end": {
1930
+ clearWatchdog();
1931
+ await writeQueue;
1932
+ const feIdx = msg.fileIndex;
1933
+ onFileEnd?.({ fileIndex: feIdx, receivedBytes: currentFileReceived });
1934
+ try {
1935
+ conn.send({ t: "file_end_ack", fileIndex: feIdx, received: currentFileReceived, size: currentFileReceived });
1936
+ } catch {
1937
+ }
1938
+ totalReceivedAllFiles += currentFileReceived;
1939
+ currentFileReceived = 0;
1940
+ resetWatchdog();
1941
+ break;
1059
1942
  }
1060
- receivedBytes += value.length;
1061
- const percent = totalBytes > 0 ? Math.round(receivedBytes / totalBytes * 100) : 0;
1062
- progress({
1063
- phase: "downloading",
1064
- text: `Downloading... (${percent}%)`,
1065
- percent,
1066
- processedBytes: receivedBytes,
1067
- totalBytes
1068
- });
1943
+ case "end":
1944
+ clearWatchdog();
1945
+ await writeQueue;
1946
+ const finalReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
1947
+ const finalTotal = fileList ? fileList.totalSize : total;
1948
+ if (finalTotal && finalReceived < finalTotal) {
1949
+ const err2 = new DropgateNetworkError(
1950
+ "Transfer ended before all data was received."
1951
+ );
1952
+ try {
1953
+ conn.send({ t: "error", message: err2.message });
1954
+ } catch {
1955
+ }
1956
+ throw err2;
1957
+ }
1958
+ try {
1959
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
1960
+ } catch {
1961
+ }
1962
+ safeComplete({ received: finalReceived, total: finalTotal });
1963
+ (async () => {
1964
+ for (let i = 0; i < 2; i++) {
1965
+ await sleep(P2P_END_ACK_RETRY_DELAY_MS);
1966
+ try {
1967
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
1968
+ } catch {
1969
+ break;
1970
+ }
1971
+ }
1972
+ })().catch(() => {
1973
+ });
1974
+ break;
1975
+ case "error":
1976
+ throw new DropgateNetworkError(msg.message || "Sender reported an error.");
1977
+ case "cancelled":
1978
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1979
+ transitionTo("cancelled");
1980
+ onCancel?.({ cancelledBy: "sender", message: msg.reason });
1981
+ cleanup();
1982
+ break;
1069
1983
  }
1984
+ } catch (err2) {
1985
+ safeError(err2);
1070
1986
  }
1071
- } catch (err) {
1072
- if (err instanceof DropgateError) throw err;
1073
- if (err instanceof Error && err.name === "AbortError") {
1074
- throw new DropgateAbortError("Download cancelled.");
1075
- }
1076
- throw new DropgateNetworkError("Download failed.", { cause: err });
1077
- } finally {
1078
- downloadCleanup();
1079
- }
1080
- progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
1081
- let data;
1082
- if (collectData && dataChunks.length > 0) {
1083
- const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
1084
- data = new Uint8Array(totalLength);
1085
- let offset = 0;
1086
- for (const chunk of dataChunks) {
1087
- data.set(chunk, offset);
1088
- offset += chunk.length;
1089
- }
1090
- }
1091
- return {
1092
- filename,
1093
- receivedBytes,
1094
- wasEncrypted: isEncrypted,
1095
- ...data ? { data } : {}
1096
- };
1097
- }
1098
- async attemptChunkUpload(url, fetchOptions, opts) {
1099
- const {
1100
- retries,
1101
- backoffMs,
1102
- maxBackoffMs,
1103
- timeoutMs,
1104
- signal,
1105
- progress,
1106
- chunkIndex,
1107
- totalChunks,
1108
- chunkSize,
1109
- fileSizeBytes
1110
- } = opts;
1111
- let attemptsLeft = retries;
1112
- let currentBackoff = backoffMs;
1113
- const maxRetries = retries;
1114
- while (true) {
1115
- if (signal?.aborted) {
1116
- throw signal.reason || new DropgateAbortError();
1987
+ });
1988
+ conn.on("close", () => {
1989
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1990
+ cleanup();
1991
+ return;
1117
1992
  }
1118
- const { signal: s, cleanup } = makeAbortSignal(signal, timeoutMs);
1119
- try {
1120
- const res = await this.fetchFn(url, { ...fetchOptions, signal: s });
1121
- if (res.ok) return;
1122
- const text = await res.text().catch(() => "");
1123
- const err = new DropgateProtocolError(
1124
- `Chunk ${chunkIndex + 1} failed (HTTP ${res.status}).`,
1125
- {
1126
- details: { status: res.status, bodySnippet: text.slice(0, 120) }
1127
- }
1128
- );
1129
- throw err;
1130
- } catch (err) {
1993
+ if (state === "transferring") {
1994
+ transitionTo("cancelled");
1995
+ onCancel?.({ cancelledBy: "sender" });
1131
1996
  cleanup();
1132
- if (err instanceof Error && (err.name === "AbortError" || err.code === "ABORT_ERR")) {
1133
- throw err;
1134
- }
1135
- if (signal?.aborted) {
1136
- throw signal.reason || new DropgateAbortError();
1137
- }
1138
- if (attemptsLeft <= 0) {
1139
- throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
1140
- }
1141
- const attemptNumber = maxRetries - attemptsLeft + 1;
1142
- const processedBytes = chunkIndex * chunkSize;
1143
- const percent = chunkIndex / totalChunks * 100;
1144
- let remaining = currentBackoff;
1145
- const tick = 100;
1146
- while (remaining > 0) {
1147
- const secondsLeft = (remaining / 1e3).toFixed(1);
1148
- progress({
1149
- phase: "retry-wait",
1150
- text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
1151
- percent,
1152
- processedBytes,
1153
- totalBytes: fileSizeBytes,
1154
- chunkIndex,
1155
- totalChunks
1156
- });
1157
- await sleep(Math.min(tick, remaining), signal);
1158
- remaining -= tick;
1159
- }
1160
- progress({
1161
- phase: "retry",
1162
- text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
1163
- percent,
1164
- processedBytes,
1165
- totalBytes: fileSizeBytes,
1166
- chunkIndex,
1167
- totalChunks
1168
- });
1169
- attemptsLeft -= 1;
1170
- currentBackoff = Math.min(currentBackoff * 2, maxBackoffMs);
1171
- continue;
1172
- } finally {
1997
+ } else if (state === "negotiating") {
1998
+ transitionTo("closed");
1173
1999
  cleanup();
2000
+ onDisconnect?.();
2001
+ } else {
2002
+ safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
1174
2003
  }
1175
- }
1176
- }
1177
- };
2004
+ });
2005
+ });
2006
+ return {
2007
+ peer,
2008
+ stop,
2009
+ getStatus: () => state,
2010
+ getBytesReceived: () => received,
2011
+ getTotalBytes: () => total,
2012
+ getSessionId: () => currentSessionId
2013
+ };
2014
+ }
1178
2015
 
1179
- // src/p2p/utils.ts
1180
- function isLocalhostHostname(hostname) {
1181
- const host = String(hostname || "").toLowerCase();
1182
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
2016
+ // src/client/DropgateClient.ts
2017
+ function resolveServerToBaseUrl(server) {
2018
+ if (typeof server === "string") {
2019
+ return buildBaseUrl(parseServerUrl(server));
2020
+ }
2021
+ return buildBaseUrl(server);
1183
2022
  }
1184
- function isSecureContextForP2P(hostname, isSecureContext) {
1185
- return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
2023
+ function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
2024
+ const base = Number(fileSizeBytes) || 0;
2025
+ if (!isEncrypted) return base;
2026
+ return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
1186
2027
  }
1187
- function generateP2PCode(cryptoObj) {
1188
- const crypto2 = cryptoObj || getDefaultCrypto();
1189
- const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
1190
- if (crypto2) {
1191
- const randomBytes = new Uint8Array(8);
1192
- crypto2.getRandomValues(randomBytes);
1193
- let letterPart = "";
1194
- for (let i = 0; i < 4; i++) {
1195
- letterPart += letters[randomBytes[i] % letters.length];
2028
+ async function getServerInfo(opts) {
2029
+ const { server, timeoutMs = 5e3, signal, fetchFn: customFetch } = opts;
2030
+ const fetchFn = customFetch || getDefaultFetch();
2031
+ if (!fetchFn) {
2032
+ throw new DropgateValidationError("No fetch() implementation found.");
2033
+ }
2034
+ const baseUrl = resolveServerToBaseUrl(server);
2035
+ try {
2036
+ const { res, json } = await fetchJson(
2037
+ fetchFn,
2038
+ `${baseUrl}/api/info`,
2039
+ {
2040
+ method: "GET",
2041
+ timeoutMs,
2042
+ signal,
2043
+ headers: { Accept: "application/json" }
2044
+ }
2045
+ );
2046
+ if (res.ok && json && typeof json === "object" && "version" in json) {
2047
+ return { baseUrl, serverInfo: json };
2048
+ }
2049
+ throw new DropgateProtocolError(
2050
+ `Server info request failed (status ${res.status}).`
2051
+ );
2052
+ } catch (err2) {
2053
+ if (err2 instanceof DropgateError) throw err2;
2054
+ throw new DropgateNetworkError("Could not reach server /api/info.", {
2055
+ cause: err2
2056
+ });
2057
+ }
2058
+ }
2059
+ var DropgateClient = class {
2060
+ /**
2061
+ * Create a new DropgateClient instance.
2062
+ * @param opts - Client configuration options including server URL.
2063
+ * @throws {DropgateValidationError} If clientVersion or server is missing or invalid.
2064
+ */
2065
+ constructor(opts) {
2066
+ /** Client version string for compatibility checking. */
2067
+ __publicField(this, "clientVersion");
2068
+ /** Chunk size in bytes for upload splitting. */
2069
+ __publicField(this, "chunkSize");
2070
+ /** Fetch implementation used for HTTP requests. */
2071
+ __publicField(this, "fetchFn");
2072
+ /** Crypto implementation for encryption operations. */
2073
+ __publicField(this, "cryptoObj");
2074
+ /** Base64 encoder/decoder for binary data. */
2075
+ __publicField(this, "base64");
2076
+ /** Resolved base URL (e.g. 'https://dropgate.link'). May change during HTTP fallback. */
2077
+ __publicField(this, "baseUrl");
2078
+ /** Whether to automatically retry with HTTP when HTTPS fails. */
2079
+ __publicField(this, "_fallbackToHttp");
2080
+ /** Cached compatibility result (null until first connect()). */
2081
+ __publicField(this, "_compat", null);
2082
+ /** In-flight connect promise to deduplicate concurrent calls. */
2083
+ __publicField(this, "_connectPromise", null);
2084
+ if (!opts || typeof opts.clientVersion !== "string") {
2085
+ throw new DropgateValidationError(
2086
+ "DropgateClient requires clientVersion (string)."
2087
+ );
2088
+ }
2089
+ if (!opts.server) {
2090
+ throw new DropgateValidationError(
2091
+ "DropgateClient requires server (URL string or ServerTarget object)."
2092
+ );
1196
2093
  }
1197
- let numberPart = "";
1198
- for (let i = 4; i < 8; i++) {
1199
- numberPart += (randomBytes[i] % 10).toString();
2094
+ this.clientVersion = opts.clientVersion;
2095
+ this.chunkSize = Number.isFinite(opts.chunkSize) ? opts.chunkSize : DEFAULT_CHUNK_SIZE;
2096
+ const fetchFn = opts.fetchFn || getDefaultFetch();
2097
+ if (!fetchFn) {
2098
+ throw new DropgateValidationError("No fetch() implementation found.");
1200
2099
  }
1201
- return `${letterPart}-${numberPart}`;
1202
- }
1203
- let a = "";
1204
- for (let i = 0; i < 4; i++) {
1205
- a += letters[Math.floor(Math.random() * letters.length)];
2100
+ this.fetchFn = fetchFn;
2101
+ const cryptoObj = opts.cryptoObj || getDefaultCrypto();
2102
+ if (!cryptoObj) {
2103
+ throw new DropgateValidationError("No crypto implementation found.");
2104
+ }
2105
+ this.cryptoObj = cryptoObj;
2106
+ this.base64 = opts.base64 || getDefaultBase64();
2107
+ this._fallbackToHttp = Boolean(opts.fallbackToHttp);
2108
+ this.baseUrl = resolveServerToBaseUrl(opts.server);
1206
2109
  }
1207
- let b = "";
1208
- for (let i = 0; i < 4; i++) {
1209
- b += Math.floor(Math.random() * 10);
2110
+ /**
2111
+ * Get the server target (host, port, secure) derived from the current baseUrl.
2112
+ * Useful for passing to standalone functions that still need a ServerTarget.
2113
+ */
2114
+ get serverTarget() {
2115
+ const url = new URL(this.baseUrl);
2116
+ return {
2117
+ host: url.hostname,
2118
+ port: url.port ? Number(url.port) : void 0,
2119
+ secure: url.protocol === "https:"
2120
+ };
1210
2121
  }
1211
- return `${a}-${b}`;
1212
- }
1213
- function isP2PCodeLike(code) {
1214
- return /^[A-Z]{4}-\d{4}$/.test(String(code || "").trim());
1215
- }
1216
-
1217
- // src/p2p/helpers.ts
1218
- function resolvePeerConfig(userConfig, serverCaps) {
1219
- return {
1220
- path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
1221
- iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
1222
- };
1223
- }
1224
- function buildPeerOptions(config = {}) {
1225
- const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
1226
- const peerOpts = {
1227
- host,
1228
- path: peerjsPath,
1229
- secure,
1230
- config: { iceServers },
1231
- debug: 0
1232
- };
1233
- if (port) {
1234
- peerOpts.port = port;
2122
+ /**
2123
+ * Connect to the server: fetch server info and check version compatibility.
2124
+ * Results are cached — subsequent calls return instantly without network requests.
2125
+ * Concurrent calls are deduplicated.
2126
+ *
2127
+ * @param opts - Optional timeout and abort signal.
2128
+ * @returns Compatibility result with server info.
2129
+ * @throws {DropgateNetworkError} If the server cannot be reached.
2130
+ * @throws {DropgateProtocolError} If the server returns an invalid response.
2131
+ */
2132
+ async connect(opts) {
2133
+ if (this._compat) return this._compat;
2134
+ if (!this._connectPromise) {
2135
+ this._connectPromise = this._fetchAndCheckCompat(opts).finally(() => {
2136
+ this._connectPromise = null;
2137
+ });
2138
+ }
2139
+ return this._connectPromise;
1235
2140
  }
1236
- return peerOpts;
1237
- }
1238
- async function createPeerWithRetries(opts) {
1239
- const { code, codeGenerator, maxAttempts, buildPeer, onCode } = opts;
1240
- let nextCode = code || codeGenerator();
1241
- let peer = null;
1242
- let lastError = null;
1243
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
1244
- onCode?.(nextCode, attempt);
2141
+ async _fetchAndCheckCompat(opts) {
2142
+ const { timeoutMs = 5e3, signal } = opts ?? {};
2143
+ let baseUrl = this.baseUrl;
2144
+ let serverInfo;
1245
2145
  try {
1246
- peer = await new Promise((resolve, reject) => {
1247
- const instance = buildPeer(nextCode);
1248
- instance.on("open", () => resolve(instance));
1249
- instance.on("error", (err) => {
1250
- try {
1251
- instance.destroy();
1252
- } catch {
1253
- }
1254
- reject(err);
1255
- });
2146
+ const result = await getServerInfo({
2147
+ server: baseUrl,
2148
+ timeoutMs,
2149
+ signal,
2150
+ fetchFn: this.fetchFn
1256
2151
  });
1257
- return { peer, code: nextCode };
1258
- } catch (err) {
1259
- lastError = err;
1260
- nextCode = codeGenerator();
2152
+ baseUrl = result.baseUrl;
2153
+ serverInfo = result.serverInfo;
2154
+ } catch (err2) {
2155
+ if (this._fallbackToHttp && this.baseUrl.startsWith("https://")) {
2156
+ const httpBaseUrl = this.baseUrl.replace("https://", "http://");
2157
+ try {
2158
+ const result = await getServerInfo({
2159
+ server: httpBaseUrl,
2160
+ timeoutMs,
2161
+ signal,
2162
+ fetchFn: this.fetchFn
2163
+ });
2164
+ this.baseUrl = httpBaseUrl;
2165
+ baseUrl = result.baseUrl;
2166
+ serverInfo = result.serverInfo;
2167
+ } catch {
2168
+ if (err2 instanceof DropgateError) throw err2;
2169
+ throw new DropgateNetworkError("Could not connect to the server.", { cause: err2 });
2170
+ }
2171
+ } else {
2172
+ if (err2 instanceof DropgateError) throw err2;
2173
+ throw new DropgateNetworkError("Could not connect to the server.", { cause: err2 });
2174
+ }
1261
2175
  }
2176
+ const compat = this._checkVersionCompat(serverInfo);
2177
+ this._compat = { ...compat, serverInfo, baseUrl };
2178
+ return this._compat;
1262
2179
  }
1263
- throw lastError || new DropgateNetworkError("Could not establish PeerJS connection.");
1264
- }
1265
-
1266
- // src/p2p/send.ts
1267
- function generateSessionId() {
1268
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
1269
- return crypto.randomUUID();
1270
- }
1271
- return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
1272
- }
1273
- async function startP2PSend(opts) {
1274
- const {
1275
- file,
1276
- Peer,
1277
- serverInfo,
1278
- host,
1279
- port,
1280
- peerjsPath,
1281
- secure = false,
1282
- iceServers,
1283
- codeGenerator,
1284
- cryptoObj,
1285
- maxAttempts = 4,
1286
- chunkSize = 256 * 1024,
1287
- endAckTimeoutMs = 15e3,
1288
- bufferHighWaterMark = 8 * 1024 * 1024,
1289
- bufferLowWaterMark = 2 * 1024 * 1024,
1290
- heartbeatIntervalMs = 5e3,
1291
- onCode,
1292
- onStatus,
1293
- onProgress,
1294
- onComplete,
1295
- onError,
1296
- onDisconnect,
1297
- onCancel
1298
- } = opts;
1299
- if (!file) {
1300
- throw new DropgateValidationError("File is missing.");
2180
+ /**
2181
+ * Pure version compatibility check (no network calls).
2182
+ */
2183
+ _checkVersionCompat(serverInfo) {
2184
+ const serverVersion = String(serverInfo?.version || "0.0.0");
2185
+ const clientVersion = String(this.clientVersion || "0.0.0");
2186
+ const c = parseSemverMajorMinor(clientVersion);
2187
+ const s = parseSemverMajorMinor(serverVersion);
2188
+ if (c.major !== s.major) {
2189
+ return {
2190
+ compatible: false,
2191
+ clientVersion,
2192
+ serverVersion,
2193
+ message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
2194
+ };
2195
+ }
2196
+ if (c.minor > s.minor) {
2197
+ return {
2198
+ compatible: true,
2199
+ clientVersion,
2200
+ serverVersion,
2201
+ message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`
2202
+ };
2203
+ }
2204
+ return {
2205
+ compatible: true,
2206
+ clientVersion,
2207
+ serverVersion,
2208
+ message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
2209
+ };
1301
2210
  }
1302
- if (!Peer) {
1303
- throw new DropgateValidationError(
1304
- "PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
2211
+ /**
2212
+ * Resolve a user-entered sharing code or URL via the server.
2213
+ * @param value - The sharing code or URL to resolve.
2214
+ * @param opts - Optional timeout and abort signal.
2215
+ * @returns The resolved share target information.
2216
+ * @throws {DropgateProtocolError} If the share lookup fails.
2217
+ */
2218
+ async resolveShareTarget(value, opts) {
2219
+ const { timeoutMs = 5e3, signal } = opts ?? {};
2220
+ const compat = await this.connect(opts);
2221
+ if (!compat.compatible) {
2222
+ throw new DropgateValidationError(compat.message);
2223
+ }
2224
+ const { baseUrl } = compat;
2225
+ const { res, json } = await fetchJson(
2226
+ this.fetchFn,
2227
+ `${baseUrl}/api/resolve`,
2228
+ {
2229
+ method: "POST",
2230
+ timeoutMs,
2231
+ signal,
2232
+ headers: {
2233
+ "Content-Type": "application/json",
2234
+ Accept: "application/json"
2235
+ },
2236
+ body: JSON.stringify({ value })
2237
+ }
1305
2238
  );
2239
+ if (!res.ok) {
2240
+ const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Share lookup failed (status ${res.status}).`;
2241
+ throw new DropgateProtocolError(msg, { details: json });
2242
+ }
2243
+ return json || { valid: false, reason: "Unknown response." };
1306
2244
  }
1307
- const p2pCaps = serverInfo?.capabilities?.p2p;
1308
- if (serverInfo && !p2pCaps?.enabled) {
1309
- throw new DropgateValidationError("Direct transfer is disabled on this server.");
2245
+ /**
2246
+ * Fetch metadata for a single file from the server.
2247
+ * @param fileId - The file ID to fetch metadata for.
2248
+ * @param opts - Optional connection options (timeout, signal).
2249
+ * @returns File metadata including size, filename, and encryption status.
2250
+ * @throws {DropgateNetworkError} If the server cannot be reached.
2251
+ * @throws {DropgateProtocolError} If the file is not found or server returns an error.
2252
+ */
2253
+ async getFileMetadata(fileId, opts) {
2254
+ if (!fileId || typeof fileId !== "string") {
2255
+ throw new DropgateValidationError("File ID is required.");
2256
+ }
2257
+ const { timeoutMs = 5e3, signal } = opts ?? {};
2258
+ const url = `${this.baseUrl}/api/file/${encodeURIComponent(fileId)}/meta`;
2259
+ const { res, json } = await fetchJson(this.fetchFn, url, {
2260
+ method: "GET",
2261
+ timeoutMs,
2262
+ signal
2263
+ });
2264
+ if (!res.ok) {
2265
+ const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Failed to fetch file metadata (status ${res.status}).`;
2266
+ throw new DropgateProtocolError(msg, { details: json });
2267
+ }
2268
+ return json;
1310
2269
  }
1311
- const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1312
- { peerjsPath, iceServers },
1313
- p2pCaps
1314
- );
1315
- const peerOpts = buildPeerOptions({
1316
- host,
1317
- port,
1318
- peerjsPath: finalPath,
1319
- secure,
1320
- iceServers: finalIceServers
1321
- });
1322
- const finalCodeGenerator = codeGenerator || (() => generateP2PCode(cryptoObj));
1323
- const buildPeer = (id) => new Peer(id, peerOpts);
1324
- const { peer, code } = await createPeerWithRetries({
1325
- code: null,
1326
- codeGenerator: finalCodeGenerator,
1327
- maxAttempts,
1328
- buildPeer,
1329
- onCode
1330
- });
1331
- const sessionId = generateSessionId();
1332
- let state = "listening";
1333
- let activeConn = null;
1334
- let sentBytes = 0;
1335
- let heartbeatTimer = null;
1336
- const reportProgress = (data) => {
1337
- const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
1338
- const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
1339
- const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
1340
- onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1341
- };
1342
- const safeError = (err) => {
1343
- if (state === "closed" || state === "completed" || state === "cancelled") return;
1344
- state = "closed";
1345
- onError?.(err);
1346
- cleanup();
1347
- };
1348
- const safeComplete = () => {
1349
- if (state !== "finishing") return;
1350
- state = "completed";
1351
- onComplete?.();
1352
- cleanup();
1353
- };
1354
- const cleanup = () => {
1355
- if (heartbeatTimer) {
1356
- clearInterval(heartbeatTimer);
1357
- heartbeatTimer = null;
2270
+ /**
2271
+ * Fetch metadata for a bundle from the server and derive computed fields.
2272
+ * For sealed bundles, decrypts the manifest to extract file list.
2273
+ * Automatically derives totalSizeBytes and fileCount from the files array.
2274
+ * @param bundleId - The bundle ID to fetch metadata for.
2275
+ * @param keyB64 - Base64-encoded decryption key (required for encrypted bundles).
2276
+ * @param opts - Optional connection options (timeout, signal).
2277
+ * @returns Complete bundle metadata with all files and computed fields.
2278
+ * @throws {DropgateNetworkError} If the server cannot be reached.
2279
+ * @throws {DropgateProtocolError} If the bundle is not found or server returns an error.
2280
+ * @throws {DropgateValidationError} If decryption key is missing for encrypted bundle.
2281
+ */
2282
+ async getBundleMetadata(bundleId, keyB64, opts) {
2283
+ if (!bundleId || typeof bundleId !== "string") {
2284
+ throw new DropgateValidationError("Bundle ID is required.");
1358
2285
  }
1359
- if (typeof window !== "undefined") {
1360
- window.removeEventListener("beforeunload", handleUnload);
2286
+ const { timeoutMs = 5e3, signal } = opts ?? {};
2287
+ const url = `${this.baseUrl}/api/bundle/${encodeURIComponent(bundleId)}/meta`;
2288
+ const { res, json } = await fetchJson(this.fetchFn, url, {
2289
+ method: "GET",
2290
+ timeoutMs,
2291
+ signal
2292
+ });
2293
+ if (!res.ok) {
2294
+ const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Failed to fetch bundle metadata (status ${res.status}).`;
2295
+ throw new DropgateProtocolError(msg, { details: json });
2296
+ }
2297
+ const serverMeta = json;
2298
+ let files = [];
2299
+ if (serverMeta.sealed && serverMeta.encryptedManifest) {
2300
+ if (!keyB64) {
2301
+ throw new DropgateValidationError(
2302
+ "Decryption key (keyB64) is required for encrypted sealed bundles."
2303
+ );
2304
+ }
2305
+ const key = await importKeyFromBase64(this.cryptoObj, keyB64);
2306
+ const encryptedBytes = this.base64.decode(serverMeta.encryptedManifest);
2307
+ const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedBytes, key);
2308
+ const manifestJson = new TextDecoder().decode(decryptedBuffer);
2309
+ const manifest = JSON.parse(manifestJson);
2310
+ files = manifest.files.map((f) => ({
2311
+ fileId: f.fileId,
2312
+ sizeBytes: f.sizeBytes,
2313
+ filename: f.name
2314
+ }));
2315
+ } else if (serverMeta.files) {
2316
+ files = serverMeta.files;
2317
+ } else {
2318
+ throw new DropgateProtocolError("Invalid bundle metadata: missing files or manifest.");
2319
+ }
2320
+ const totalSizeBytes = files.reduce((sum, f) => sum + (f.sizeBytes || 0), 0);
2321
+ const fileCount = files.length;
2322
+ return {
2323
+ isEncrypted: serverMeta.isEncrypted,
2324
+ sealed: serverMeta.sealed,
2325
+ encryptedManifest: serverMeta.encryptedManifest,
2326
+ files,
2327
+ totalSizeBytes,
2328
+ fileCount
2329
+ };
2330
+ }
2331
+ /**
2332
+ * Validate file and upload settings against server capabilities.
2333
+ * @param opts - Validation options containing file, settings, and server info.
2334
+ * @returns True if validation passes.
2335
+ * @throws {DropgateValidationError} If any validation check fails.
2336
+ */
2337
+ validateUploadInputs(opts) {
2338
+ const { files: rawFiles, lifetimeMs, encrypt, serverInfo } = opts;
2339
+ const caps = serverInfo?.capabilities?.upload;
2340
+ if (!caps || !caps.enabled) {
2341
+ throw new DropgateValidationError("Server does not support file uploads.");
1361
2342
  }
1362
- try {
1363
- activeConn?.close();
1364
- } catch {
2343
+ const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
2344
+ if (files.length === 0) {
2345
+ throw new DropgateValidationError("At least one file is required.");
1365
2346
  }
1366
- try {
1367
- peer.destroy();
1368
- } catch {
2347
+ for (let i = 0; i < files.length; i++) {
2348
+ const file = files[i];
2349
+ const fileSize = Number(file?.size || 0);
2350
+ if (!file || !Number.isFinite(fileSize) || fileSize <= 0) {
2351
+ throw new DropgateValidationError(`File at index ${i} is missing or invalid.`);
2352
+ }
2353
+ const maxMB = Number(caps.maxSizeMB);
2354
+ if (Number.isFinite(maxMB) && maxMB > 0) {
2355
+ const limitBytes = maxMB * 1e3 * 1e3;
2356
+ const validationChunkSize = Number.isFinite(caps.chunkSize) && caps.chunkSize > 0 ? caps.chunkSize : this.chunkSize;
2357
+ const totalChunks = Math.ceil(fileSize / validationChunkSize);
2358
+ const estimatedBytes = estimateTotalUploadSizeBytes(
2359
+ fileSize,
2360
+ totalChunks,
2361
+ Boolean(encrypt)
2362
+ );
2363
+ if (estimatedBytes > limitBytes) {
2364
+ const msg = encrypt ? `File at index ${i} too large once encryption overhead is included. Server limit: ${maxMB} MB.` : `File at index ${i} too large. Server limit: ${maxMB} MB.`;
2365
+ throw new DropgateValidationError(msg);
2366
+ }
2367
+ }
1369
2368
  }
1370
- };
1371
- const handleUnload = () => {
1372
- try {
1373
- activeConn?.send({ t: "error", message: "Sender closed the connection." });
1374
- } catch {
2369
+ const maxHours = Number(caps.maxLifetimeHours);
2370
+ const lt = Number(lifetimeMs);
2371
+ if (!Number.isFinite(lt) || lt < 0 || !Number.isInteger(lt)) {
2372
+ throw new DropgateValidationError(
2373
+ "Invalid lifetime. Must be a non-negative integer (milliseconds)."
2374
+ );
1375
2375
  }
1376
- stop();
1377
- };
1378
- if (typeof window !== "undefined") {
1379
- window.addEventListener("beforeunload", handleUnload);
1380
- }
1381
- const stop = () => {
1382
- if (state === "closed" || state === "cancelled") return;
1383
- const wasActive = state === "transferring" || state === "finishing";
1384
- state = "cancelled";
1385
- try {
1386
- if (activeConn && activeConn.open) {
1387
- activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
2376
+ if (Number.isFinite(maxHours) && maxHours > 0) {
2377
+ const limitMs = Math.round(maxHours * 60 * 60 * 1e3);
2378
+ if (lt === 0) {
2379
+ throw new DropgateValidationError(
2380
+ `Server does not allow unlimited file lifetime. Max: ${maxHours} hours.`
2381
+ );
2382
+ }
2383
+ if (lt > limitMs) {
2384
+ throw new DropgateValidationError(
2385
+ `File lifetime too long. Server limit: ${maxHours} hours.`
2386
+ );
1388
2387
  }
1389
- } catch {
1390
2388
  }
1391
- if (wasActive && onCancel) {
1392
- onCancel({ cancelledBy: "sender" });
2389
+ if (encrypt && !caps.e2ee) {
2390
+ throw new DropgateValidationError(
2391
+ "End-to-end encryption is not supported on this server."
2392
+ );
1393
2393
  }
1394
- cleanup();
1395
- };
1396
- const isStopped = () => state === "closed" || state === "cancelled";
1397
- peer.on("connection", (conn) => {
1398
- if (state === "closed") return;
1399
- if (activeConn) {
1400
- const isOldConnOpen = activeConn.open !== false;
1401
- if (isOldConnOpen && state === "transferring") {
1402
- try {
1403
- conn.send({ t: "error", message: "Transfer already in progress." });
1404
- } catch {
2394
+ return true;
2395
+ }
2396
+ /**
2397
+ * Upload one or more files to the server with optional encryption.
2398
+ * Single files use the standard upload protocol.
2399
+ * Multiple files use the bundle protocol, grouping files under a single download link.
2400
+ *
2401
+ * @param opts - Upload options including file(s) and settings.
2402
+ * @returns Upload session with result promise and cancellation support.
2403
+ */
2404
+ async uploadFiles(opts) {
2405
+ const {
2406
+ files: rawFiles,
2407
+ lifetimeMs,
2408
+ encrypt,
2409
+ maxDownloads,
2410
+ filenameOverrides,
2411
+ onProgress,
2412
+ onCancel,
2413
+ signal,
2414
+ timeouts = {},
2415
+ retry = {}
2416
+ } = opts;
2417
+ const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
2418
+ if (files.length === 0) {
2419
+ throw new DropgateValidationError("At least one file is required.");
2420
+ }
2421
+ const internalController = signal ? null : new AbortController();
2422
+ const effectiveSignal = signal || internalController?.signal;
2423
+ let uploadState = "initializing";
2424
+ const currentUploadIds = [];
2425
+ const totalSizeBytes = files.reduce((sum, f) => sum + f.size, 0);
2426
+ const uploadPromise = (async () => {
2427
+ try {
2428
+ const progress = (evt) => {
2429
+ try {
2430
+ if (onProgress) onProgress(evt);
2431
+ } catch {
2432
+ }
2433
+ };
2434
+ progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
2435
+ const compat = await this.connect({
2436
+ timeoutMs: timeouts.serverInfoMs ?? 5e3,
2437
+ signal: effectiveSignal
2438
+ });
2439
+ const { baseUrl, serverInfo } = compat;
2440
+ progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
2441
+ if (!compat.compatible) {
2442
+ throw new DropgateValidationError(compat.message);
1405
2443
  }
1406
- try {
1407
- conn.close();
1408
- } catch {
2444
+ const filenames = files.map((f, i) => filenameOverrides?.[i] ?? f.name ?? "file");
2445
+ const serverSupportsE2EE = Boolean(serverInfo?.capabilities?.upload?.e2ee);
2446
+ const effectiveEncrypt = encrypt ?? serverSupportsE2EE;
2447
+ if (!effectiveEncrypt) {
2448
+ for (const name of filenames) validatePlainFilename(name);
1409
2449
  }
1410
- return;
1411
- } else if (!isOldConnOpen) {
1412
- try {
1413
- activeConn.close();
1414
- } catch {
2450
+ this.validateUploadInputs({ files, lifetimeMs, encrypt: effectiveEncrypt, serverInfo });
2451
+ let cryptoKey = null;
2452
+ let keyB64 = null;
2453
+ const transmittedFilenames = [];
2454
+ if (effectiveEncrypt) {
2455
+ if (!this.cryptoObj?.subtle) {
2456
+ throw new DropgateValidationError(
2457
+ "Web Crypto API not available (crypto.subtle). Encryption requires a secure context (HTTPS or localhost)."
2458
+ );
2459
+ }
2460
+ progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: totalSizeBytes });
2461
+ try {
2462
+ cryptoKey = await generateAesGcmKey(this.cryptoObj);
2463
+ keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
2464
+ for (const name of filenames) {
2465
+ transmittedFilenames.push(
2466
+ await encryptFilenameToBase64(this.cryptoObj, name, cryptoKey)
2467
+ );
2468
+ }
2469
+ } catch (err2) {
2470
+ throw new DropgateError("Failed to prepare encryption.", { code: "CRYPTO_PREP_FAILED", cause: err2 });
2471
+ }
2472
+ } else {
2473
+ transmittedFilenames.push(...filenames);
1415
2474
  }
1416
- activeConn = null;
1417
- state = "listening";
1418
- sentBytes = 0;
1419
- } else {
1420
- try {
1421
- conn.send({ t: "error", message: "Another receiver is already connected." });
1422
- } catch {
2475
+ const serverChunkSize = serverInfo?.capabilities?.upload?.chunkSize;
2476
+ const effectiveChunkSize = Number.isFinite(serverChunkSize) && serverChunkSize > 0 ? serverChunkSize : this.chunkSize;
2477
+ const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
2478
+ const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
2479
+ const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
2480
+ if (files.length === 1) {
2481
+ const file = files[0];
2482
+ const totalChunks = Math.ceil(file.size / effectiveChunkSize);
2483
+ const totalUploadSize = estimateTotalUploadSizeBytes(file.size, totalChunks, effectiveEncrypt);
2484
+ progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: file.size });
2485
+ const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
2486
+ method: "POST",
2487
+ timeoutMs: timeouts.initMs ?? 15e3,
2488
+ signal: effectiveSignal,
2489
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2490
+ body: JSON.stringify({
2491
+ filename: transmittedFilenames[0],
2492
+ lifetime: lifetimeMs,
2493
+ isEncrypted: effectiveEncrypt,
2494
+ totalSize: totalUploadSize,
2495
+ totalChunks,
2496
+ ...maxDownloads !== void 0 ? { maxDownloads } : {}
2497
+ })
2498
+ });
2499
+ if (!initRes.res.ok) {
2500
+ const errorJson = initRes.json;
2501
+ throw new DropgateProtocolError(errorJson?.error || `Server initialisation failed: ${initRes.res.status}`, { details: initRes.json || initRes.text });
2502
+ }
2503
+ const uploadId = initRes.json?.uploadId;
2504
+ if (!uploadId) throw new DropgateProtocolError("Server did not return a valid uploadId.");
2505
+ currentUploadIds.push(uploadId);
2506
+ uploadState = "uploading";
2507
+ await this._uploadFileChunks({
2508
+ file,
2509
+ uploadId,
2510
+ cryptoKey,
2511
+ effectiveChunkSize,
2512
+ totalChunks,
2513
+ totalUploadSize,
2514
+ baseOffset: 0,
2515
+ totalBytesAllFiles: file.size,
2516
+ progress,
2517
+ signal: effectiveSignal,
2518
+ baseUrl,
2519
+ retries,
2520
+ backoffMs: baseBackoffMs,
2521
+ maxBackoffMs,
2522
+ chunkTimeoutMs: timeouts.chunkMs ?? 6e4
2523
+ });
2524
+ progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: file.size, totalBytes: file.size });
2525
+ uploadState = "completing";
2526
+ const completeRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete`, {
2527
+ method: "POST",
2528
+ timeoutMs: timeouts.completeMs ?? 3e4,
2529
+ signal: effectiveSignal,
2530
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2531
+ body: JSON.stringify({ uploadId })
2532
+ });
2533
+ if (!completeRes.res.ok) {
2534
+ const errorJson = completeRes.json;
2535
+ throw new DropgateProtocolError(errorJson?.error || "Finalisation failed.", { details: completeRes.json || completeRes.text });
2536
+ }
2537
+ const fileId = completeRes.json?.id;
2538
+ if (!fileId) throw new DropgateProtocolError("Server did not return a valid file id.");
2539
+ let downloadUrl2 = `${baseUrl}/${fileId}`;
2540
+ if (effectiveEncrypt && keyB64) downloadUrl2 += `#${keyB64}`;
2541
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: file.size, totalBytes: file.size });
2542
+ uploadState = "completed";
2543
+ return {
2544
+ downloadUrl: downloadUrl2,
2545
+ fileId,
2546
+ uploadId,
2547
+ baseUrl,
2548
+ ...effectiveEncrypt && keyB64 ? { keyB64 } : {}
2549
+ };
1423
2550
  }
1424
- try {
1425
- conn.close();
1426
- } catch {
2551
+ const fileManifest = files.map((f, i) => {
2552
+ const totalChunks = Math.ceil(f.size / effectiveChunkSize);
2553
+ const totalUploadSize = estimateTotalUploadSizeBytes(f.size, totalChunks, effectiveEncrypt);
2554
+ return { filename: transmittedFilenames[i], totalSize: totalUploadSize, totalChunks };
2555
+ });
2556
+ progress({ phase: "init", text: `Reserving server storage for ${files.length} files...`, percent: 0, processedBytes: 0, totalBytes: totalSizeBytes, totalFiles: files.length });
2557
+ const initBundleRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init-bundle`, {
2558
+ method: "POST",
2559
+ timeoutMs: timeouts.initMs ?? 15e3,
2560
+ signal: effectiveSignal,
2561
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2562
+ body: JSON.stringify({
2563
+ fileCount: files.length,
2564
+ files: fileManifest,
2565
+ lifetime: lifetimeMs,
2566
+ isEncrypted: effectiveEncrypt,
2567
+ ...maxDownloads !== void 0 ? { maxDownloads } : {}
2568
+ })
2569
+ });
2570
+ if (!initBundleRes.res.ok) {
2571
+ const errorJson = initBundleRes.json;
2572
+ throw new DropgateProtocolError(errorJson?.error || `Bundle initialisation failed: ${initBundleRes.res.status}`, { details: initBundleRes.json || initBundleRes.text });
1427
2573
  }
1428
- return;
2574
+ const bundleInitJson = initBundleRes.json;
2575
+ const bundleUploadId = bundleInitJson?.bundleUploadId;
2576
+ const fileUploadIds = bundleInitJson?.fileUploadIds;
2577
+ if (!bundleUploadId || !fileUploadIds || fileUploadIds.length !== files.length) {
2578
+ throw new DropgateProtocolError("Server did not return valid bundle upload IDs.");
2579
+ }
2580
+ currentUploadIds.push(...fileUploadIds);
2581
+ uploadState = "uploading";
2582
+ const fileResults = [];
2583
+ let cumulativeBytes = 0;
2584
+ for (let fi = 0; fi < files.length; fi++) {
2585
+ const file = files[fi];
2586
+ const uploadId = fileUploadIds[fi];
2587
+ const totalChunks = fileManifest[fi].totalChunks;
2588
+ const totalUploadSize = fileManifest[fi].totalSize;
2589
+ progress({
2590
+ phase: "file-start",
2591
+ text: `Uploading file ${fi + 1} of ${files.length}: ${filenames[fi]}`,
2592
+ percent: totalSizeBytes > 0 ? cumulativeBytes / totalSizeBytes * 100 : 0,
2593
+ processedBytes: cumulativeBytes,
2594
+ totalBytes: totalSizeBytes,
2595
+ fileIndex: fi,
2596
+ totalFiles: files.length,
2597
+ currentFileName: filenames[fi]
2598
+ });
2599
+ await this._uploadFileChunks({
2600
+ file,
2601
+ uploadId,
2602
+ cryptoKey,
2603
+ effectiveChunkSize,
2604
+ totalChunks,
2605
+ totalUploadSize,
2606
+ baseOffset: cumulativeBytes,
2607
+ totalBytesAllFiles: totalSizeBytes,
2608
+ progress,
2609
+ signal: effectiveSignal,
2610
+ baseUrl,
2611
+ retries,
2612
+ backoffMs: baseBackoffMs,
2613
+ maxBackoffMs,
2614
+ chunkTimeoutMs: timeouts.chunkMs ?? 6e4,
2615
+ fileIndex: fi,
2616
+ totalFiles: files.length,
2617
+ currentFileName: filenames[fi]
2618
+ });
2619
+ const completeRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete`, {
2620
+ method: "POST",
2621
+ timeoutMs: timeouts.completeMs ?? 3e4,
2622
+ signal: effectiveSignal,
2623
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2624
+ body: JSON.stringify({ uploadId })
2625
+ });
2626
+ if (!completeRes.res.ok) {
2627
+ const errorJson = completeRes.json;
2628
+ throw new DropgateProtocolError(errorJson?.error || `File ${fi + 1} finalisation failed.`, { details: completeRes.json || completeRes.text });
2629
+ }
2630
+ const fileId = completeRes.json?.id;
2631
+ if (!fileId) throw new DropgateProtocolError(`Server did not return a valid file id for file ${fi + 1}.`);
2632
+ fileResults.push({ fileId, name: filenames[fi], size: file.size });
2633
+ cumulativeBytes += file.size;
2634
+ progress({
2635
+ phase: "file-complete",
2636
+ text: `File ${fi + 1} of ${files.length} uploaded.`,
2637
+ percent: totalSizeBytes > 0 ? cumulativeBytes / totalSizeBytes * 100 : 0,
2638
+ processedBytes: cumulativeBytes,
2639
+ totalBytes: totalSizeBytes,
2640
+ fileIndex: fi,
2641
+ totalFiles: files.length,
2642
+ currentFileName: filenames[fi]
2643
+ });
2644
+ }
2645
+ progress({ phase: "complete", text: "Finalising bundle...", percent: 100, processedBytes: totalSizeBytes, totalBytes: totalSizeBytes });
2646
+ uploadState = "completing";
2647
+ let encryptedManifestB64;
2648
+ if (effectiveEncrypt && cryptoKey) {
2649
+ const manifest = JSON.stringify({
2650
+ files: fileResults.map((r) => ({
2651
+ fileId: r.fileId,
2652
+ name: r.name,
2653
+ sizeBytes: r.size
2654
+ }))
2655
+ });
2656
+ const manifestBytes = new TextEncoder().encode(manifest);
2657
+ const encryptedBlob = await encryptToBlob(this.cryptoObj, manifestBytes.buffer, cryptoKey);
2658
+ const encryptedBuffer = new Uint8Array(await encryptedBlob.arrayBuffer());
2659
+ encryptedManifestB64 = this.base64.encode(encryptedBuffer);
2660
+ }
2661
+ const completeBundleRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/complete-bundle`, {
2662
+ method: "POST",
2663
+ timeoutMs: timeouts.completeMs ?? 3e4,
2664
+ signal: effectiveSignal,
2665
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2666
+ body: JSON.stringify({
2667
+ bundleUploadId,
2668
+ ...encryptedManifestB64 ? { encryptedManifest: encryptedManifestB64 } : {}
2669
+ })
2670
+ });
2671
+ if (!completeBundleRes.res.ok) {
2672
+ const errorJson = completeBundleRes.json;
2673
+ throw new DropgateProtocolError(errorJson?.error || "Bundle finalisation failed.", { details: completeBundleRes.json || completeBundleRes.text });
2674
+ }
2675
+ const bundleId = completeBundleRes.json?.bundleId;
2676
+ if (!bundleId) throw new DropgateProtocolError("Server did not return a valid bundle id.");
2677
+ let downloadUrl = `${baseUrl}/b/${bundleId}`;
2678
+ if (effectiveEncrypt && keyB64) downloadUrl += `#${keyB64}`;
2679
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: totalSizeBytes, totalBytes: totalSizeBytes });
2680
+ uploadState = "completed";
2681
+ return {
2682
+ downloadUrl,
2683
+ bundleId,
2684
+ baseUrl,
2685
+ files: fileResults,
2686
+ ...effectiveEncrypt && keyB64 ? { keyB64 } : {}
2687
+ };
2688
+ } catch (err2) {
2689
+ if (err2 instanceof Error && (err2.name === "AbortError" || err2.message?.includes("abort"))) {
2690
+ uploadState = "cancelled";
2691
+ onCancel?.();
2692
+ } else {
2693
+ uploadState = "error";
2694
+ }
2695
+ throw err2;
1429
2696
  }
1430
- }
1431
- activeConn = conn;
1432
- state = "negotiating";
1433
- onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
1434
- let readyResolve = null;
1435
- let ackResolve = null;
1436
- const readyPromise = new Promise((resolve) => {
1437
- readyResolve = resolve;
1438
- });
1439
- const ackPromise = new Promise((resolve) => {
1440
- ackResolve = resolve;
1441
- });
1442
- conn.on("data", (data) => {
1443
- if (!data || typeof data !== "object" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
1444
- return;
2697
+ })();
2698
+ const callCancelEndpoint = async (uploadId) => {
2699
+ try {
2700
+ await fetchJson(this.fetchFn, `${this.baseUrl}/upload/cancel`, {
2701
+ method: "POST",
2702
+ timeoutMs: 5e3,
2703
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2704
+ body: JSON.stringify({ uploadId })
2705
+ });
2706
+ } catch {
1445
2707
  }
1446
- const msg = data;
1447
- if (!msg.t) return;
1448
- if (msg.t === "ready") {
1449
- onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
1450
- readyResolve?.();
1451
- return;
2708
+ };
2709
+ return {
2710
+ result: uploadPromise,
2711
+ cancel: (reason) => {
2712
+ if (uploadState === "completed" || uploadState === "cancelled") return;
2713
+ uploadState = "cancelled";
2714
+ for (const id of currentUploadIds) {
2715
+ callCancelEndpoint(id).catch(() => {
2716
+ });
2717
+ }
2718
+ internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
2719
+ },
2720
+ getStatus: () => uploadState
2721
+ };
2722
+ }
2723
+ /**
2724
+ * Upload a single file's chunks to the server. Used internally by uploadFiles().
2725
+ */
2726
+ async _uploadFileChunks(params) {
2727
+ const {
2728
+ file,
2729
+ uploadId,
2730
+ cryptoKey,
2731
+ effectiveChunkSize,
2732
+ totalChunks,
2733
+ baseOffset,
2734
+ totalBytesAllFiles,
2735
+ progress,
2736
+ signal,
2737
+ baseUrl,
2738
+ retries,
2739
+ backoffMs,
2740
+ maxBackoffMs,
2741
+ chunkTimeoutMs,
2742
+ fileIndex,
2743
+ totalFiles,
2744
+ currentFileName
2745
+ } = params;
2746
+ for (let i = 0; i < totalChunks; i++) {
2747
+ if (signal?.aborted) {
2748
+ throw signal.reason || new DropgateAbortError();
1452
2749
  }
1453
- if (msg.t === "progress") {
1454
- reportProgress({ received: msg.received || 0, total: msg.total || 0 });
1455
- return;
2750
+ const start = i * effectiveChunkSize;
2751
+ const end = Math.min(start + effectiveChunkSize, file.size);
2752
+ const chunkSlice = file.slice(start, end);
2753
+ const processedBytes = baseOffset + start;
2754
+ const percent = totalBytesAllFiles > 0 ? processedBytes / totalBytesAllFiles * 100 : 0;
2755
+ progress({
2756
+ phase: "chunk",
2757
+ text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
2758
+ percent,
2759
+ processedBytes,
2760
+ totalBytes: totalBytesAllFiles,
2761
+ chunkIndex: i,
2762
+ totalChunks,
2763
+ ...fileIndex !== void 0 ? { fileIndex, totalFiles, currentFileName } : {}
2764
+ });
2765
+ const chunkBuffer = await chunkSlice.arrayBuffer();
2766
+ let uploadBlob;
2767
+ if (cryptoKey) {
2768
+ uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
2769
+ } else {
2770
+ uploadBlob = new Blob([chunkBuffer]);
1456
2771
  }
1457
- if (msg.t === "ack" && msg.phase === "end") {
1458
- ackResolve?.(msg);
1459
- return;
2772
+ if (uploadBlob.size > effectiveChunkSize + 1024) {
2773
+ throw new DropgateValidationError("Chunk too large (client-side). Check chunk size settings.");
2774
+ }
2775
+ const toHash = await uploadBlob.arrayBuffer();
2776
+ const hashHex = await sha256Hex(this.cryptoObj, toHash);
2777
+ await this._attemptChunkUpload(
2778
+ `${baseUrl}/upload/chunk`,
2779
+ { method: "POST", headers: { "Content-Type": "application/octet-stream", "X-Upload-ID": uploadId, "X-Chunk-Index": String(i), "X-Chunk-Hash": hashHex }, body: uploadBlob },
2780
+ { retries, backoffMs, maxBackoffMs, timeoutMs: chunkTimeoutMs, signal, progress, chunkIndex: i, totalChunks, chunkSize: effectiveChunkSize, fileSizeBytes: totalBytesAllFiles }
2781
+ );
2782
+ }
2783
+ }
2784
+ /**
2785
+ * Download one or more files from the server with optional decryption.
2786
+ *
2787
+ * For single files, use `fileId`. For bundles, use `bundleId`.
2788
+ * With `asZip: true` on bundles, streams a ZIP archive via `onData`.
2789
+ * Without `asZip`, delivers files individually via `onFileStart`/`onFileData`/`onFileEnd`.
2790
+ *
2791
+ * @param opts - Download options including file/bundle ID and optional key.
2792
+ * @returns Download result containing filename(s) and received bytes.
2793
+ */
2794
+ async downloadFiles(opts) {
2795
+ const {
2796
+ fileId,
2797
+ bundleId,
2798
+ keyB64,
2799
+ asZip,
2800
+ zipFilename: _zipFilename,
2801
+ onProgress,
2802
+ onData,
2803
+ onFileStart,
2804
+ onFileData,
2805
+ onFileEnd,
2806
+ signal,
2807
+ timeoutMs = 6e4
2808
+ } = opts;
2809
+ const progress = (evt) => {
2810
+ try {
2811
+ if (onProgress) onProgress(evt);
2812
+ } catch {
1460
2813
  }
1461
- if (msg.t === "pong") {
1462
- return;
2814
+ };
2815
+ if (!fileId && !bundleId) {
2816
+ throw new DropgateValidationError("Either fileId or bundleId is required.");
2817
+ }
2818
+ progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
2819
+ const compat = await this.connect({ timeoutMs, signal });
2820
+ const { baseUrl } = compat;
2821
+ progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
2822
+ if (!compat.compatible) throw new DropgateValidationError(compat.message);
2823
+ if (fileId) {
2824
+ return this._downloadSingleFile({ fileId, keyB64, onProgress, onData, signal, timeoutMs, baseUrl, compat });
2825
+ }
2826
+ progress({ phase: "metadata", text: "Fetching bundle info...", processedBytes: 0, totalBytes: 0, percent: 0 });
2827
+ let bundleMeta;
2828
+ try {
2829
+ bundleMeta = await this.getBundleMetadata(bundleId, keyB64, { timeoutMs, signal });
2830
+ } catch (err2) {
2831
+ if (err2 instanceof DropgateError) throw err2;
2832
+ if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
2833
+ throw new DropgateNetworkError("Could not fetch bundle metadata.", { cause: err2 });
2834
+ }
2835
+ const isEncrypted = Boolean(bundleMeta.isEncrypted);
2836
+ const totalBytes = bundleMeta.totalSizeBytes || 0;
2837
+ let cryptoKey;
2838
+ const filenames = [];
2839
+ if (isEncrypted) {
2840
+ if (!keyB64) throw new DropgateValidationError("Decryption key is required for encrypted bundles.");
2841
+ if (!this.cryptoObj?.subtle) throw new DropgateValidationError("Web Crypto API not available for decryption.");
2842
+ try {
2843
+ cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
2844
+ if (bundleMeta.sealed && bundleMeta.encryptedManifest) {
2845
+ const encryptedBytes = this.base64.decode(bundleMeta.encryptedManifest);
2846
+ const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedBytes, cryptoKey);
2847
+ const manifestJson = new TextDecoder().decode(decryptedBuffer);
2848
+ const manifest = JSON.parse(manifestJson);
2849
+ bundleMeta.files = manifest.files.map((f) => ({
2850
+ fileId: f.fileId,
2851
+ sizeBytes: f.sizeBytes,
2852
+ filename: f.name
2853
+ }));
2854
+ bundleMeta.fileCount = bundleMeta.files.length;
2855
+ for (const f of bundleMeta.files) {
2856
+ filenames.push(f.filename || "file");
2857
+ }
2858
+ } else {
2859
+ for (const f of bundleMeta.files) {
2860
+ filenames.push(await decryptFilenameFromBase64(this.cryptoObj, f.encryptedFilename, cryptoKey, this.base64));
2861
+ }
2862
+ }
2863
+ } catch (err2) {
2864
+ throw new DropgateError("Failed to decrypt bundle manifest.", { code: "DECRYPT_MANIFEST_FAILED", cause: err2 });
1463
2865
  }
1464
- if (msg.t === "error") {
1465
- safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1466
- return;
2866
+ } else {
2867
+ for (const f of bundleMeta.files) {
2868
+ filenames.push(f.filename || "file");
1467
2869
  }
1468
- if (msg.t === "cancelled") {
1469
- if (state === "cancelled" || state === "closed" || state === "completed") return;
1470
- state = "cancelled";
1471
- onCancel?.({ cancelledBy: "receiver", message: msg.message });
1472
- cleanup();
2870
+ }
2871
+ let totalReceivedBytes = 0;
2872
+ if (asZip && onData) {
2873
+ const zipWriter = new StreamingZipWriter(onData);
2874
+ for (let fi = 0; fi < bundleMeta.files.length; fi++) {
2875
+ const fileMeta = bundleMeta.files[fi];
2876
+ const name = filenames[fi];
2877
+ progress({
2878
+ phase: "zipping",
2879
+ text: `Downloading ${name}...`,
2880
+ percent: totalBytes > 0 ? totalReceivedBytes / totalBytes * 100 : 0,
2881
+ processedBytes: totalReceivedBytes,
2882
+ totalBytes,
2883
+ fileIndex: fi,
2884
+ totalFiles: bundleMeta.files.length,
2885
+ currentFileName: name
2886
+ });
2887
+ zipWriter.startFile(name);
2888
+ const baseReceivedBytes = totalReceivedBytes;
2889
+ const bytesReceived = await this._streamFileIntoCallback(
2890
+ baseUrl,
2891
+ fileMeta.fileId,
2892
+ isEncrypted,
2893
+ cryptoKey,
2894
+ compat,
2895
+ signal,
2896
+ timeoutMs,
2897
+ (chunk) => {
2898
+ zipWriter.writeChunk(chunk);
2899
+ },
2900
+ (fileBytes) => {
2901
+ const current = baseReceivedBytes + fileBytes;
2902
+ progress({
2903
+ phase: "zipping",
2904
+ text: `Downloading ${name}...`,
2905
+ percent: totalBytes > 0 ? current / totalBytes * 100 : 0,
2906
+ processedBytes: current,
2907
+ totalBytes,
2908
+ fileIndex: fi,
2909
+ totalFiles: bundleMeta.files.length,
2910
+ currentFileName: name
2911
+ });
2912
+ }
2913
+ );
2914
+ zipWriter.endFile();
2915
+ totalReceivedBytes += bytesReceived;
1473
2916
  }
1474
- });
1475
- conn.on("open", async () => {
2917
+ await zipWriter.finalize();
1476
2918
  try {
1477
- if (isStopped()) return;
1478
- conn.send({
1479
- t: "meta",
1480
- sessionId,
1481
- name: file.name,
1482
- size: file.size,
1483
- mime: file.type || "application/octet-stream"
2919
+ await fetchJson(this.fetchFn, `${baseUrl}/api/bundle/${bundleId}/downloaded`, {
2920
+ method: "POST",
2921
+ timeoutMs: 5e3,
2922
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2923
+ body: "{}"
1484
2924
  });
1485
- const total = file.size;
1486
- const dc = conn._dc;
1487
- if (dc && Number.isFinite(bufferLowWaterMark)) {
1488
- try {
1489
- dc.bufferedAmountLowThreshold = bufferLowWaterMark;
1490
- } catch {
1491
- }
1492
- }
1493
- await readyPromise;
1494
- if (isStopped()) return;
1495
- if (heartbeatIntervalMs > 0) {
1496
- heartbeatTimer = setInterval(() => {
1497
- if (state === "transferring" || state === "finishing") {
1498
- try {
1499
- conn.send({ t: "ping" });
1500
- } catch {
1501
- }
1502
- }
1503
- }, heartbeatIntervalMs);
1504
- }
1505
- state = "transferring";
1506
- for (let offset = 0; offset < total; offset += chunkSize) {
1507
- if (isStopped()) return;
1508
- const slice = file.slice(offset, offset + chunkSize);
1509
- const buf = await slice.arrayBuffer();
1510
- if (isStopped()) return;
1511
- conn.send(buf);
1512
- sentBytes += buf.byteLength;
1513
- if (dc) {
1514
- while (dc.bufferedAmount > bufferHighWaterMark) {
1515
- await new Promise((resolve) => {
1516
- const fallback = setTimeout(resolve, 60);
1517
- try {
1518
- dc.addEventListener(
1519
- "bufferedamountlow",
1520
- () => {
1521
- clearTimeout(fallback);
1522
- resolve();
1523
- },
1524
- { once: true }
1525
- );
1526
- } catch {
1527
- }
1528
- });
1529
- }
2925
+ } catch {
2926
+ }
2927
+ progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: totalReceivedBytes, totalBytes });
2928
+ return { filenames, receivedBytes: totalReceivedBytes, wasEncrypted: isEncrypted };
2929
+ } else {
2930
+ const dataCallback = onFileData || onData;
2931
+ for (let fi = 0; fi < bundleMeta.files.length; fi++) {
2932
+ const fileMeta = bundleMeta.files[fi];
2933
+ const name = filenames[fi];
2934
+ progress({
2935
+ phase: "downloading",
2936
+ text: `Downloading ${name}...`,
2937
+ percent: totalBytes > 0 ? totalReceivedBytes / totalBytes * 100 : 0,
2938
+ processedBytes: totalReceivedBytes,
2939
+ totalBytes,
2940
+ fileIndex: fi,
2941
+ totalFiles: bundleMeta.files.length,
2942
+ currentFileName: name
2943
+ });
2944
+ onFileStart?.({ name, size: fileMeta.sizeBytes, index: fi });
2945
+ const baseReceivedBytes = totalReceivedBytes;
2946
+ const bytesReceived = await this._streamFileIntoCallback(
2947
+ baseUrl,
2948
+ fileMeta.fileId,
2949
+ isEncrypted,
2950
+ cryptoKey,
2951
+ compat,
2952
+ signal,
2953
+ timeoutMs,
2954
+ dataCallback ? (chunk) => dataCallback(chunk) : void 0,
2955
+ (fileBytes) => {
2956
+ const current = baseReceivedBytes + fileBytes;
2957
+ progress({
2958
+ phase: "downloading",
2959
+ text: `Downloading ${name}...`,
2960
+ percent: totalBytes > 0 ? current / totalBytes * 100 : 0,
2961
+ processedBytes: current,
2962
+ totalBytes,
2963
+ fileIndex: fi,
2964
+ totalFiles: bundleMeta.files.length,
2965
+ currentFileName: name
2966
+ });
1530
2967
  }
1531
- }
1532
- if (isStopped()) return;
1533
- state = "finishing";
1534
- conn.send({ t: "end" });
1535
- const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
1536
- const ackResult = await Promise.race([
1537
- ackPromise,
1538
- sleep(ackTimeoutMs || 15e3).catch(() => null)
1539
- ]);
1540
- if (isStopped()) return;
1541
- if (!ackResult || typeof ackResult !== "object") {
1542
- throw new DropgateNetworkError("Receiver did not confirm completion.");
1543
- }
1544
- const ackData = ackResult;
1545
- const ackTotal = Number(ackData.total) || file.size;
1546
- const ackReceived = Number(ackData.received) || 0;
1547
- if (ackTotal && ackReceived < ackTotal) {
1548
- throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
1549
- }
1550
- reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
1551
- safeComplete();
1552
- } catch (err) {
1553
- safeError(err);
2968
+ );
2969
+ onFileEnd?.({ name, index: fi });
2970
+ totalReceivedBytes += bytesReceived;
1554
2971
  }
1555
- });
1556
- conn.on("error", (err) => {
1557
- safeError(err);
1558
- });
1559
- conn.on("close", () => {
1560
- if (state === "closed" || state === "completed" || state === "cancelled") {
1561
- cleanup();
1562
- return;
2972
+ progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: totalReceivedBytes, totalBytes });
2973
+ return { filenames, receivedBytes: totalReceivedBytes, wasEncrypted: isEncrypted };
2974
+ }
2975
+ }
2976
+ /**
2977
+ * Download a single file, handling encryption/decryption internally.
2978
+ * Preserves the original downloadFile() behavior.
2979
+ */
2980
+ async _downloadSingleFile(params) {
2981
+ const { fileId, keyB64, onProgress, onData, signal, timeoutMs, baseUrl, compat } = params;
2982
+ const progress = (evt) => {
2983
+ try {
2984
+ if (onProgress) onProgress(evt);
2985
+ } catch {
1563
2986
  }
1564
- if (state === "transferring" || state === "finishing") {
1565
- state = "cancelled";
1566
- onCancel?.({ cancelledBy: "receiver" });
1567
- cleanup();
1568
- } else {
1569
- activeConn = null;
1570
- state = "listening";
1571
- sentBytes = 0;
1572
- onDisconnect?.();
2987
+ };
2988
+ progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
2989
+ let metadata;
2990
+ try {
2991
+ metadata = await this.getFileMetadata(fileId, { timeoutMs, signal });
2992
+ } catch (err2) {
2993
+ if (err2 instanceof DropgateError) throw err2;
2994
+ if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
2995
+ throw new DropgateNetworkError("Could not fetch file metadata.", { cause: err2 });
2996
+ }
2997
+ const isEncrypted = Boolean(metadata.isEncrypted);
2998
+ const totalBytes = metadata.sizeBytes || 0;
2999
+ if (!onData && totalBytes > MAX_IN_MEMORY_DOWNLOAD_BYTES) {
3000
+ const sizeMB = Math.round(totalBytes / (1024 * 1024));
3001
+ const limitMB = Math.round(MAX_IN_MEMORY_DOWNLOAD_BYTES / (1024 * 1024));
3002
+ throw new DropgateValidationError(
3003
+ `File is too large (${sizeMB}MB) to download without streaming. Provide an onData callback to stream files larger than ${limitMB}MB.`
3004
+ );
3005
+ }
3006
+ let filename;
3007
+ let cryptoKey;
3008
+ if (isEncrypted) {
3009
+ if (!keyB64) throw new DropgateValidationError("Decryption key is required for encrypted files.");
3010
+ if (!this.cryptoObj?.subtle) throw new DropgateValidationError("Web Crypto API not available for decryption.");
3011
+ progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
3012
+ try {
3013
+ cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
3014
+ filename = await decryptFilenameFromBase64(this.cryptoObj, metadata.encryptedFilename, cryptoKey, this.base64);
3015
+ } catch (err2) {
3016
+ throw new DropgateError("Failed to decrypt filename.", { code: "DECRYPT_FILENAME_FAILED", cause: err2 });
1573
3017
  }
1574
- });
1575
- });
1576
- return {
1577
- peer,
1578
- code,
1579
- sessionId,
1580
- stop,
1581
- getStatus: () => state,
1582
- getBytesSent: () => sentBytes,
1583
- getConnectedPeerId: () => {
1584
- if (!activeConn) return null;
1585
- return activeConn.peer || null;
3018
+ } else {
3019
+ filename = metadata.filename || "file";
1586
3020
  }
1587
- };
1588
- }
1589
-
1590
- // src/p2p/receive.ts
1591
- async function startP2PReceive(opts) {
1592
- const {
1593
- code,
1594
- Peer,
1595
- serverInfo,
1596
- host,
1597
- port,
1598
- peerjsPath,
1599
- secure = false,
1600
- iceServers,
1601
- autoReady = true,
1602
- watchdogTimeoutMs = 15e3,
1603
- onStatus,
1604
- onMeta,
1605
- onData,
1606
- onProgress,
1607
- onComplete,
1608
- onError,
1609
- onDisconnect,
1610
- onCancel
1611
- } = opts;
1612
- if (!code) {
1613
- throw new DropgateValidationError("No sharing code was provided.");
1614
- }
1615
- if (!Peer) {
1616
- throw new DropgateValidationError(
1617
- "PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
3021
+ progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
3022
+ const dataChunks = [];
3023
+ const collectData = !onData;
3024
+ const receivedBytes = await this._streamFileIntoCallback(
3025
+ baseUrl,
3026
+ fileId,
3027
+ isEncrypted,
3028
+ cryptoKey,
3029
+ compat,
3030
+ signal,
3031
+ timeoutMs,
3032
+ async (chunk) => {
3033
+ if (collectData) {
3034
+ dataChunks.push(chunk);
3035
+ } else {
3036
+ await onData(chunk);
3037
+ }
3038
+ },
3039
+ (bytes) => {
3040
+ progress({
3041
+ phase: "downloading",
3042
+ text: "Downloading...",
3043
+ percent: totalBytes > 0 ? bytes / totalBytes * 100 : 0,
3044
+ processedBytes: bytes,
3045
+ totalBytes
3046
+ });
3047
+ }
1618
3048
  );
1619
- }
1620
- const p2pCaps = serverInfo?.capabilities?.p2p;
1621
- if (serverInfo && !p2pCaps?.enabled) {
1622
- throw new DropgateValidationError("Direct transfer is disabled on this server.");
1623
- }
1624
- const normalizedCode = String(code).trim().replace(/\s+/g, "").toUpperCase();
1625
- if (!isP2PCodeLike(normalizedCode)) {
1626
- throw new DropgateValidationError("Invalid direct transfer code.");
1627
- }
1628
- const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
1629
- { peerjsPath, iceServers },
1630
- p2pCaps
1631
- );
1632
- const peerOpts = buildPeerOptions({
1633
- host,
1634
- port,
1635
- peerjsPath: finalPath,
1636
- secure,
1637
- iceServers: finalIceServers
1638
- });
1639
- const peer = new Peer(void 0, peerOpts);
1640
- let state = "initializing";
1641
- let total = 0;
1642
- let received = 0;
1643
- let currentSessionId = null;
1644
- let lastProgressSentAt = 0;
1645
- const progressIntervalMs = 120;
1646
- let writeQueue = Promise.resolve();
1647
- let watchdogTimer = null;
1648
- let activeConn = null;
1649
- const resetWatchdog = () => {
1650
- if (watchdogTimeoutMs <= 0) return;
1651
- if (watchdogTimer) {
1652
- clearTimeout(watchdogTimer);
3049
+ progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
3050
+ let data;
3051
+ if (collectData && dataChunks.length > 0) {
3052
+ const totalLength = dataChunks.reduce((sum, c) => sum + c.length, 0);
3053
+ data = new Uint8Array(totalLength);
3054
+ let offset = 0;
3055
+ for (const c of dataChunks) {
3056
+ data.set(c, offset);
3057
+ offset += c.length;
3058
+ }
1653
3059
  }
1654
- watchdogTimer = setTimeout(() => {
1655
- if (state === "transferring") {
1656
- safeError(new DropgateNetworkError("Connection timed out (no data received)."));
3060
+ return {
3061
+ filename,
3062
+ receivedBytes,
3063
+ wasEncrypted: isEncrypted,
3064
+ ...data ? { data } : {}
3065
+ };
3066
+ }
3067
+ /**
3068
+ * Stream a single file's content into a callback, handling decryption if needed.
3069
+ * Returns total bytes received from the network (encrypted size).
3070
+ */
3071
+ async _streamFileIntoCallback(baseUrl, fileId, isEncrypted, cryptoKey, compat, signal, timeoutMs, onChunk, onBytesReceived) {
3072
+ const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
3073
+ let receivedBytes = 0;
3074
+ try {
3075
+ const downloadRes = await this.fetchFn(`${baseUrl}/api/file/${fileId}`, {
3076
+ method: "GET",
3077
+ signal: downloadSignal
3078
+ });
3079
+ if (!downloadRes.ok) throw new DropgateProtocolError(`Download failed (status ${downloadRes.status}).`);
3080
+ if (!downloadRes.body) throw new DropgateProtocolError("Streaming response not available.");
3081
+ const reader = downloadRes.body.getReader();
3082
+ if (isEncrypted && cryptoKey) {
3083
+ const downloadChunkSize = Number.isFinite(compat.serverInfo?.capabilities?.upload?.chunkSize) && compat.serverInfo.capabilities.upload.chunkSize > 0 ? compat.serverInfo.capabilities.upload.chunkSize : this.chunkSize;
3084
+ const ENCRYPTED_CHUNK_SIZE = downloadChunkSize + ENCRYPTION_OVERHEAD_PER_CHUNK;
3085
+ const pendingChunks = [];
3086
+ let pendingLength = 0;
3087
+ const flushPending = () => {
3088
+ if (pendingChunks.length === 0) return new Uint8Array(0);
3089
+ if (pendingChunks.length === 1) {
3090
+ const result2 = pendingChunks[0];
3091
+ pendingChunks.length = 0;
3092
+ pendingLength = 0;
3093
+ return result2;
3094
+ }
3095
+ const result = new Uint8Array(pendingLength);
3096
+ let offset = 0;
3097
+ for (const chunk of pendingChunks) {
3098
+ result.set(chunk, offset);
3099
+ offset += chunk.length;
3100
+ }
3101
+ pendingChunks.length = 0;
3102
+ pendingLength = 0;
3103
+ return result;
3104
+ };
3105
+ while (true) {
3106
+ if (signal?.aborted) throw new DropgateAbortError("Download cancelled.");
3107
+ const { done, value } = await reader.read();
3108
+ if (done) break;
3109
+ pendingChunks.push(value);
3110
+ pendingLength += value.length;
3111
+ receivedBytes += value.length;
3112
+ if (onBytesReceived) onBytesReceived(receivedBytes);
3113
+ while (pendingLength >= ENCRYPTED_CHUNK_SIZE) {
3114
+ const buffer = flushPending();
3115
+ const encryptedChunk = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
3116
+ if (buffer.length > ENCRYPTED_CHUNK_SIZE) {
3117
+ pendingChunks.push(buffer.subarray(ENCRYPTED_CHUNK_SIZE));
3118
+ pendingLength = buffer.length - ENCRYPTED_CHUNK_SIZE;
3119
+ }
3120
+ const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedChunk, cryptoKey);
3121
+ if (onChunk) await onChunk(new Uint8Array(decryptedBuffer));
3122
+ }
3123
+ }
3124
+ if (pendingLength > 0) {
3125
+ const buffer = flushPending();
3126
+ const decryptedBuffer = await decryptChunk(this.cryptoObj, buffer, cryptoKey);
3127
+ if (onChunk) await onChunk(new Uint8Array(decryptedBuffer));
3128
+ }
3129
+ } else {
3130
+ while (true) {
3131
+ if (signal?.aborted) throw new DropgateAbortError("Download cancelled.");
3132
+ const { done, value } = await reader.read();
3133
+ if (done) break;
3134
+ receivedBytes += value.length;
3135
+ if (onBytesReceived) onBytesReceived(receivedBytes);
3136
+ if (onChunk) await onChunk(value);
3137
+ }
1657
3138
  }
1658
- }, watchdogTimeoutMs);
1659
- };
1660
- const clearWatchdog = () => {
1661
- if (watchdogTimer) {
1662
- clearTimeout(watchdogTimer);
1663
- watchdogTimer = null;
1664
- }
1665
- };
1666
- const safeError = (err) => {
1667
- if (state === "closed" || state === "completed" || state === "cancelled") return;
1668
- state = "closed";
1669
- onError?.(err);
1670
- cleanup();
1671
- };
1672
- const safeComplete = (completeData) => {
1673
- if (state !== "transferring") return;
1674
- state = "completed";
1675
- onComplete?.(completeData);
1676
- cleanup();
1677
- };
1678
- const cleanup = () => {
1679
- clearWatchdog();
1680
- if (typeof window !== "undefined") {
1681
- window.removeEventListener("beforeunload", handleUnload);
3139
+ } catch (err2) {
3140
+ if (err2 instanceof DropgateError) throw err2;
3141
+ if (err2 instanceof Error && err2.name === "AbortError") throw new DropgateAbortError("Download cancelled.");
3142
+ throw new DropgateNetworkError("Download failed.", { cause: err2 });
3143
+ } finally {
3144
+ downloadCleanup();
1682
3145
  }
1683
- try {
1684
- peer.destroy();
1685
- } catch {
3146
+ return receivedBytes;
3147
+ }
3148
+ /**
3149
+ * Start a P2P send session. Connects to the signalling server and waits for a receiver.
3150
+ *
3151
+ * Server info, peerjsPath, iceServers, and cryptoObj are provided automatically
3152
+ * from the client's cached server info and configuration.
3153
+ *
3154
+ * @param opts - P2P send options (file, Peer constructor, callbacks, tuning).
3155
+ * @returns P2P send session with control methods.
3156
+ * @throws {DropgateValidationError} If P2P is not enabled on the server.
3157
+ * @throws {DropgateNetworkError} If the signalling server cannot be reached.
3158
+ */
3159
+ async p2pSend(opts) {
3160
+ const compat = await this.connect();
3161
+ if (!compat.compatible) {
3162
+ throw new DropgateValidationError(compat.message);
1686
3163
  }
1687
- };
1688
- const handleUnload = () => {
1689
- try {
1690
- activeConn?.send({ t: "error", message: "Receiver closed the connection." });
1691
- } catch {
3164
+ const { serverInfo } = compat;
3165
+ const p2pCaps = serverInfo?.capabilities?.p2p;
3166
+ if (!p2pCaps?.enabled) {
3167
+ throw new DropgateValidationError("Direct transfer is disabled on this server.");
1692
3168
  }
1693
- stop();
1694
- };
1695
- if (typeof window !== "undefined") {
1696
- window.addEventListener("beforeunload", handleUnload);
3169
+ const { host, port, secure } = this.serverTarget;
3170
+ const { path: peerjsPath, iceServers } = resolvePeerConfig({}, p2pCaps);
3171
+ return startP2PSend({
3172
+ ...opts,
3173
+ host,
3174
+ port,
3175
+ secure,
3176
+ peerjsPath,
3177
+ iceServers,
3178
+ serverInfo,
3179
+ cryptoObj: this.cryptoObj
3180
+ });
1697
3181
  }
1698
- const stop = () => {
1699
- if (state === "closed" || state === "cancelled") return;
1700
- const wasActive = state === "transferring";
1701
- state = "cancelled";
1702
- try {
1703
- if (activeConn && activeConn.open) {
1704
- activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
1705
- }
1706
- } catch {
3182
+ /**
3183
+ * Start a P2P receive session. Connects to a sender via their sharing code.
3184
+ *
3185
+ * Server info, peerjsPath, and iceServers are provided automatically
3186
+ * from the client's cached server info.
3187
+ *
3188
+ * @param opts - P2P receive options (code, Peer constructor, callbacks, tuning).
3189
+ * @returns P2P receive session with control methods.
3190
+ * @throws {DropgateValidationError} If P2P is not enabled on the server.
3191
+ * @throws {DropgateNetworkError} If the signalling server cannot be reached.
3192
+ */
3193
+ async p2pReceive(opts) {
3194
+ const compat = await this.connect();
3195
+ if (!compat.compatible) {
3196
+ throw new DropgateValidationError(compat.message);
1707
3197
  }
1708
- if (wasActive && onCancel) {
1709
- onCancel({ cancelledBy: "receiver" });
3198
+ const { serverInfo } = compat;
3199
+ const p2pCaps = serverInfo?.capabilities?.p2p;
3200
+ if (!p2pCaps?.enabled) {
3201
+ throw new DropgateValidationError("Direct transfer is disabled on this server.");
1710
3202
  }
1711
- cleanup();
1712
- };
1713
- peer.on("error", (err) => {
1714
- safeError(err);
1715
- });
1716
- peer.on("open", () => {
1717
- state = "connecting";
1718
- const conn = peer.connect(normalizedCode, { reliable: true });
1719
- activeConn = conn;
1720
- conn.on("open", () => {
1721
- state = "negotiating";
1722
- onStatus?.({ phase: "connected", message: "Waiting for file details..." });
3203
+ const { host, port, secure } = this.serverTarget;
3204
+ const { path: peerjsPath, iceServers } = resolvePeerConfig({}, p2pCaps);
3205
+ return startP2PReceive({
3206
+ ...opts,
3207
+ host,
3208
+ port,
3209
+ secure,
3210
+ peerjsPath,
3211
+ iceServers,
3212
+ serverInfo
1723
3213
  });
1724
- conn.on("data", async (data) => {
3214
+ }
3215
+ async _attemptChunkUpload(url, fetchOptions, opts) {
3216
+ const {
3217
+ retries,
3218
+ backoffMs,
3219
+ maxBackoffMs,
3220
+ timeoutMs,
3221
+ signal,
3222
+ progress,
3223
+ chunkIndex,
3224
+ totalChunks,
3225
+ chunkSize,
3226
+ fileSizeBytes
3227
+ } = opts;
3228
+ let attemptsLeft = retries;
3229
+ let currentBackoff = backoffMs;
3230
+ const maxRetries = retries;
3231
+ while (true) {
3232
+ if (signal?.aborted) {
3233
+ throw signal.reason || new DropgateAbortError();
3234
+ }
3235
+ const { signal: s, cleanup } = makeAbortSignal(signal, timeoutMs);
1725
3236
  try {
1726
- resetWatchdog();
1727
- if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
1728
- const msg = data;
1729
- if (msg.t === "meta") {
1730
- if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
1731
- try {
1732
- conn.send({ t: "error", message: "Busy with another session." });
1733
- } catch {
1734
- }
1735
- return;
1736
- }
1737
- if (msg.sessionId) {
1738
- currentSessionId = msg.sessionId;
1739
- }
1740
- const name = String(msg.name || "file");
1741
- total = Number(msg.size) || 0;
1742
- received = 0;
1743
- writeQueue = Promise.resolve();
1744
- const sendReady = () => {
1745
- state = "transferring";
1746
- resetWatchdog();
1747
- try {
1748
- conn.send({ t: "ready" });
1749
- } catch {
1750
- }
1751
- };
1752
- if (autoReady) {
1753
- onMeta?.({ name, total });
1754
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1755
- sendReady();
1756
- } else {
1757
- onMeta?.({ name, total, sendReady });
1758
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1759
- }
1760
- return;
1761
- }
1762
- if (msg.t === "ping") {
1763
- try {
1764
- conn.send({ t: "pong" });
1765
- } catch {
1766
- }
1767
- return;
1768
- }
1769
- if (msg.t === "end") {
1770
- clearWatchdog();
1771
- await writeQueue;
1772
- if (total && received < total) {
1773
- const err = new DropgateNetworkError(
1774
- "Transfer ended before the full file was received."
1775
- );
1776
- try {
1777
- conn.send({ t: "error", message: err.message });
1778
- } catch {
1779
- }
1780
- throw err;
1781
- }
1782
- try {
1783
- conn.send({ t: "ack", phase: "end", received, total });
1784
- } catch {
1785
- }
1786
- safeComplete({ received, total });
1787
- return;
1788
- }
1789
- if (msg.t === "error") {
1790
- throw new DropgateNetworkError(msg.message || "Sender reported an error.");
1791
- }
1792
- if (msg.t === "cancelled") {
1793
- if (state === "cancelled" || state === "closed" || state === "completed") return;
1794
- state = "cancelled";
1795
- onCancel?.({ cancelledBy: "sender", message: msg.message });
1796
- cleanup();
1797
- return;
3237
+ const res = await this.fetchFn(url, { ...fetchOptions, signal: s });
3238
+ if (res.ok) return;
3239
+ const text = await res.text().catch(() => "");
3240
+ const err2 = new DropgateProtocolError(
3241
+ `Chunk ${chunkIndex + 1} failed (HTTP ${res.status}).`,
3242
+ {
3243
+ details: { status: res.status, bodySnippet: text.slice(0, 120) }
1798
3244
  }
1799
- return;
3245
+ );
3246
+ throw err2;
3247
+ } catch (err2) {
3248
+ cleanup();
3249
+ if (err2 instanceof Error && (err2.name === "AbortError" || err2.code === "ABORT_ERR")) {
3250
+ throw err2;
1800
3251
  }
1801
- let bufPromise;
1802
- if (data instanceof ArrayBuffer) {
1803
- bufPromise = Promise.resolve(new Uint8Array(data));
1804
- } else if (ArrayBuffer.isView(data)) {
1805
- bufPromise = Promise.resolve(
1806
- new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
1807
- );
1808
- } else if (typeof Blob !== "undefined" && data instanceof Blob) {
1809
- bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
1810
- } else {
1811
- return;
3252
+ if (signal?.aborted) {
3253
+ throw signal.reason || new DropgateAbortError();
1812
3254
  }
1813
- writeQueue = writeQueue.then(async () => {
1814
- const buf = await bufPromise;
1815
- if (onData) {
1816
- await onData(buf);
1817
- }
1818
- received += buf.byteLength;
1819
- const percent = total ? Math.min(100, received / total * 100) : 0;
1820
- onProgress?.({ processedBytes: received, totalBytes: total, percent });
1821
- const now = Date.now();
1822
- if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
1823
- lastProgressSentAt = now;
1824
- try {
1825
- conn.send({ t: "progress", received, total });
1826
- } catch {
1827
- }
1828
- }
1829
- }).catch((err) => {
1830
- try {
1831
- conn.send({
1832
- t: "error",
1833
- message: err?.message || "Receiver write failed."
1834
- });
1835
- } catch {
1836
- }
1837
- safeError(err);
3255
+ if (attemptsLeft <= 0) {
3256
+ throw err2 instanceof DropgateError ? err2 : new DropgateNetworkError("Chunk upload failed.", { cause: err2 });
3257
+ }
3258
+ const attemptNumber = maxRetries - attemptsLeft + 1;
3259
+ const processedBytes = chunkIndex * chunkSize;
3260
+ const percent = chunkIndex / totalChunks * 100;
3261
+ let remaining = currentBackoff;
3262
+ const tick = 100;
3263
+ while (remaining > 0) {
3264
+ const secondsLeft = (remaining / 1e3).toFixed(1);
3265
+ progress({
3266
+ phase: "retry-wait",
3267
+ text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
3268
+ percent,
3269
+ processedBytes,
3270
+ totalBytes: fileSizeBytes,
3271
+ chunkIndex,
3272
+ totalChunks
3273
+ });
3274
+ await sleep(Math.min(tick, remaining), signal);
3275
+ remaining -= tick;
3276
+ }
3277
+ progress({
3278
+ phase: "retry",
3279
+ text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
3280
+ percent,
3281
+ processedBytes,
3282
+ totalBytes: fileSizeBytes,
3283
+ chunkIndex,
3284
+ totalChunks
1838
3285
  });
1839
- } catch (err) {
1840
- safeError(err);
1841
- }
1842
- });
1843
- conn.on("close", () => {
1844
- if (state === "closed" || state === "completed" || state === "cancelled") {
1845
- cleanup();
1846
- return;
1847
- }
1848
- if (state === "transferring") {
1849
- state = "cancelled";
1850
- onCancel?.({ cancelledBy: "sender" });
1851
- cleanup();
1852
- } else if (state === "negotiating") {
1853
- state = "closed";
3286
+ attemptsLeft -= 1;
3287
+ currentBackoff = Math.min(currentBackoff * 2, maxBackoffMs);
3288
+ continue;
3289
+ } finally {
1854
3290
  cleanup();
1855
- onDisconnect?.();
1856
- } else {
1857
- safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
1858
3291
  }
1859
- });
1860
- });
1861
- return {
1862
- peer,
1863
- stop,
1864
- getStatus: () => state,
1865
- getBytesReceived: () => received,
1866
- getTotalBytes: () => total,
1867
- getSessionId: () => currentSessionId
1868
- };
1869
- }
1870
- // Annotate the CommonJS export names for ESM import in node:
1871
- 0 && (module.exports = {
1872
- AES_GCM_IV_BYTES,
1873
- AES_GCM_TAG_BYTES,
1874
- DEFAULT_CHUNK_SIZE,
1875
- DropgateAbortError,
1876
- DropgateClient,
1877
- DropgateError,
1878
- DropgateNetworkError,
1879
- DropgateProtocolError,
1880
- DropgateTimeoutError,
1881
- DropgateValidationError,
1882
- ENCRYPTION_OVERHEAD_PER_CHUNK,
1883
- arrayBufferToBase64,
1884
- base64ToBytes,
1885
- buildBaseUrl,
1886
- buildPeerOptions,
1887
- bytesToBase64,
1888
- createPeerWithRetries,
1889
- decryptChunk,
1890
- decryptFilenameFromBase64,
1891
- encryptFilenameToBase64,
1892
- encryptToBlob,
1893
- estimateTotalUploadSizeBytes,
1894
- exportKeyBase64,
1895
- fetchJson,
1896
- generateAesGcmKey,
1897
- generateP2PCode,
1898
- getDefaultBase64,
1899
- getDefaultCrypto,
1900
- getDefaultFetch,
1901
- getServerInfo,
1902
- importKeyFromBase64,
1903
- isLocalhostHostname,
1904
- isP2PCodeLike,
1905
- isSecureContextForP2P,
1906
- lifetimeToMs,
1907
- makeAbortSignal,
1908
- parseSemverMajorMinor,
1909
- parseServerUrl,
1910
- resolvePeerConfig,
1911
- sha256Hex,
1912
- sleep,
1913
- startP2PReceive,
1914
- startP2PSend,
1915
- validatePlainFilename
1916
- });
3292
+ }
3293
+ }
3294
+ };
1917
3295
  //# sourceMappingURL=index.cjs.map