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