@dropgate/core 2.0.0-beta.2 → 2.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -15
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.cjs +639 -303
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +208 -121
- package/dist/index.d.ts +208 -121
- package/dist/index.js +637 -303
- package/dist/index.js.map +1 -1
- package/dist/p2p/index.cjs +310 -65
- package/dist/p2p/index.cjs.map +1 -1
- package/dist/p2p/index.d.cts +165 -92
- package/dist/p2p/index.d.ts +165 -92
- package/dist/p2p/index.js +309 -65
- package/dist/p2p/index.js.map +1 -1
- package/package.json +88 -88
package/dist/index.cjs
CHANGED
|
@@ -49,6 +49,7 @@ __export(index_exports, {
|
|
|
49
49
|
getDefaultBase64: () => getDefaultBase64,
|
|
50
50
|
getDefaultCrypto: () => getDefaultCrypto,
|
|
51
51
|
getDefaultFetch: () => getDefaultFetch,
|
|
52
|
+
getServerInfo: () => getServerInfo,
|
|
52
53
|
importKeyFromBase64: () => importKeyFromBase64,
|
|
53
54
|
isLocalhostHostname: () => isLocalhostHostname,
|
|
54
55
|
isP2PCodeLike: () => isP2PCodeLike,
|
|
@@ -57,6 +58,7 @@ __export(index_exports, {
|
|
|
57
58
|
makeAbortSignal: () => makeAbortSignal,
|
|
58
59
|
parseSemverMajorMinor: () => parseSemverMajorMinor,
|
|
59
60
|
parseServerUrl: () => parseServerUrl,
|
|
61
|
+
resolvePeerConfig: () => resolvePeerConfig,
|
|
60
62
|
sha256Hex: () => sha256Hex,
|
|
61
63
|
sleep: () => sleep,
|
|
62
64
|
startP2PReceive: () => startP2PReceive,
|
|
@@ -376,6 +378,37 @@ function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
|
|
|
376
378
|
if (!isEncrypted) return base;
|
|
377
379
|
return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
|
|
378
380
|
}
|
|
381
|
+
async function getServerInfo(opts) {
|
|
382
|
+
const { host, port, secure, timeoutMs = 5e3, signal, fetchFn: customFetch } = opts;
|
|
383
|
+
const fetchFn = customFetch || getDefaultFetch();
|
|
384
|
+
if (!fetchFn) {
|
|
385
|
+
throw new DropgateValidationError("No fetch() implementation found.");
|
|
386
|
+
}
|
|
387
|
+
const baseUrl = buildBaseUrl({ host, port, secure });
|
|
388
|
+
try {
|
|
389
|
+
const { res, json } = await fetchJson(
|
|
390
|
+
fetchFn,
|
|
391
|
+
`${baseUrl}/api/info`,
|
|
392
|
+
{
|
|
393
|
+
method: "GET",
|
|
394
|
+
timeoutMs,
|
|
395
|
+
signal,
|
|
396
|
+
headers: { Accept: "application/json" }
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
if (res.ok && json && typeof json === "object" && "version" in json) {
|
|
400
|
+
return { baseUrl, serverInfo: json };
|
|
401
|
+
}
|
|
402
|
+
throw new DropgateProtocolError(
|
|
403
|
+
`Server info request failed (status ${res.status}).`
|
|
404
|
+
);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (err instanceof DropgateError) throw err;
|
|
407
|
+
throw new DropgateNetworkError("Could not reach server /api/info.", {
|
|
408
|
+
cause: err
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
379
412
|
var DropgateClient = class {
|
|
380
413
|
/**
|
|
381
414
|
* Create a new DropgateClient instance.
|
|
@@ -403,40 +436,6 @@ var DropgateClient = class {
|
|
|
403
436
|
this.base64 = opts.base64 || getDefaultBase64();
|
|
404
437
|
this.logger = opts.logger || null;
|
|
405
438
|
}
|
|
406
|
-
/**
|
|
407
|
-
* Fetch server information from the /api/info endpoint.
|
|
408
|
-
* @param opts - Server target and request options.
|
|
409
|
-
* @returns The server base URL and server info object.
|
|
410
|
-
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
411
|
-
* @throws {DropgateProtocolError} If the server returns an invalid response.
|
|
412
|
-
*/
|
|
413
|
-
async getServerInfo(opts) {
|
|
414
|
-
const { host, port, secure, timeoutMs = 5e3, signal } = opts;
|
|
415
|
-
const baseUrl = buildBaseUrl({ host, port, secure });
|
|
416
|
-
try {
|
|
417
|
-
const { res, json } = await fetchJson(
|
|
418
|
-
this.fetchFn,
|
|
419
|
-
`${baseUrl}/api/info`,
|
|
420
|
-
{
|
|
421
|
-
method: "GET",
|
|
422
|
-
timeoutMs,
|
|
423
|
-
signal,
|
|
424
|
-
headers: { Accept: "application/json" }
|
|
425
|
-
}
|
|
426
|
-
);
|
|
427
|
-
if (res.ok && json && typeof json === "object" && "version" in json) {
|
|
428
|
-
return { baseUrl, serverInfo: json };
|
|
429
|
-
}
|
|
430
|
-
throw new DropgateProtocolError(
|
|
431
|
-
`Server info request failed (status ${res.status}).`
|
|
432
|
-
);
|
|
433
|
-
} catch (err) {
|
|
434
|
-
if (err instanceof DropgateError) throw err;
|
|
435
|
-
throw new DropgateNetworkError("Could not reach server /api/info.", {
|
|
436
|
-
cause: err
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
439
|
/**
|
|
441
440
|
* Resolve a user-entered sharing code or URL via the server.
|
|
442
441
|
* @param value - The sharing code or URL to resolve.
|
|
@@ -445,8 +444,12 @@ var DropgateClient = class {
|
|
|
445
444
|
* @throws {DropgateProtocolError} If the share lookup fails.
|
|
446
445
|
*/
|
|
447
446
|
async resolveShareTarget(value, opts) {
|
|
448
|
-
const {
|
|
449
|
-
const
|
|
447
|
+
const { timeoutMs = 5e3, signal } = opts;
|
|
448
|
+
const compat = await this.checkCompatibility(opts);
|
|
449
|
+
if (!compat.compatible) {
|
|
450
|
+
throw new DropgateValidationError(compat.message);
|
|
451
|
+
}
|
|
452
|
+
const { baseUrl } = compat;
|
|
450
453
|
const { res, json } = await fetchJson(
|
|
451
454
|
this.fetchFn,
|
|
452
455
|
`${baseUrl}/api/resolve`,
|
|
@@ -469,10 +472,25 @@ var DropgateClient = class {
|
|
|
469
472
|
}
|
|
470
473
|
/**
|
|
471
474
|
* Check version compatibility between this client and a server.
|
|
472
|
-
*
|
|
473
|
-
* @
|
|
475
|
+
* Fetches server info internally using getServerInfo.
|
|
476
|
+
* @param opts - Server target and request options.
|
|
477
|
+
* @returns Compatibility result with status, message, and server info.
|
|
478
|
+
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
479
|
+
* @throws {DropgateProtocolError} If the server returns an invalid response.
|
|
474
480
|
*/
|
|
475
|
-
checkCompatibility(
|
|
481
|
+
async checkCompatibility(opts) {
|
|
482
|
+
let baseUrl;
|
|
483
|
+
let serverInfo;
|
|
484
|
+
try {
|
|
485
|
+
const result = await getServerInfo({ ...opts, fetchFn: this.fetchFn });
|
|
486
|
+
baseUrl = result.baseUrl;
|
|
487
|
+
serverInfo = result.serverInfo;
|
|
488
|
+
} catch (err) {
|
|
489
|
+
if (err instanceof DropgateError) throw err;
|
|
490
|
+
throw new DropgateNetworkError("Could not connect to the server.", {
|
|
491
|
+
cause: err
|
|
492
|
+
});
|
|
493
|
+
}
|
|
476
494
|
const serverVersion = String(serverInfo?.version || "0.0.0");
|
|
477
495
|
const clientVersion = String(this.clientVersion || "0.0.0");
|
|
478
496
|
const c = parseSemverMajorMinor(clientVersion);
|
|
@@ -482,7 +500,9 @@ var DropgateClient = class {
|
|
|
482
500
|
compatible: false,
|
|
483
501
|
clientVersion,
|
|
484
502
|
serverVersion,
|
|
485
|
-
message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}
|
|
503
|
+
message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
|
|
504
|
+
serverInfo,
|
|
505
|
+
baseUrl
|
|
486
506
|
};
|
|
487
507
|
}
|
|
488
508
|
if (c.minor > s.minor) {
|
|
@@ -490,14 +510,18 @@ var DropgateClient = class {
|
|
|
490
510
|
compatible: true,
|
|
491
511
|
clientVersion,
|
|
492
512
|
serverVersion,
|
|
493
|
-
message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work
|
|
513
|
+
message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`,
|
|
514
|
+
serverInfo,
|
|
515
|
+
baseUrl
|
|
494
516
|
};
|
|
495
517
|
}
|
|
496
518
|
return {
|
|
497
519
|
compatible: true,
|
|
498
520
|
clientVersion,
|
|
499
521
|
serverVersion,
|
|
500
|
-
message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}
|
|
522
|
+
message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
|
|
523
|
+
serverInfo,
|
|
524
|
+
baseUrl
|
|
501
525
|
};
|
|
502
526
|
}
|
|
503
527
|
/**
|
|
@@ -576,206 +600,251 @@ var DropgateClient = class {
|
|
|
576
600
|
encrypt,
|
|
577
601
|
filenameOverride,
|
|
578
602
|
onProgress,
|
|
603
|
+
onCancel,
|
|
579
604
|
signal,
|
|
580
605
|
timeouts = {},
|
|
581
606
|
retry = {}
|
|
582
607
|
} = opts;
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (!this.cryptoObj?.subtle) {
|
|
590
|
-
throw new DropgateValidationError(
|
|
591
|
-
"Web Crypto API not available (crypto.subtle)."
|
|
592
|
-
);
|
|
593
|
-
}
|
|
594
|
-
progress({ phase: "server-info", text: "Checking server..." });
|
|
595
|
-
let baseUrl;
|
|
596
|
-
let serverInfo;
|
|
597
|
-
try {
|
|
598
|
-
const res = await this.getServerInfo({
|
|
599
|
-
host,
|
|
600
|
-
port,
|
|
601
|
-
secure,
|
|
602
|
-
timeoutMs: timeouts.serverInfoMs ?? 5e3,
|
|
603
|
-
signal
|
|
604
|
-
});
|
|
605
|
-
baseUrl = res.baseUrl;
|
|
606
|
-
serverInfo = res.serverInfo;
|
|
607
|
-
} catch (err) {
|
|
608
|
-
if (err instanceof DropgateError) throw err;
|
|
609
|
-
throw new DropgateNetworkError("Could not connect to the server.", {
|
|
610
|
-
cause: err
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
const compat = this.checkCompatibility(serverInfo);
|
|
614
|
-
progress({ phase: "server-compat", text: compat.message });
|
|
615
|
-
if (!compat.compatible) {
|
|
616
|
-
throw new DropgateValidationError(compat.message);
|
|
617
|
-
}
|
|
618
|
-
const filename = filenameOverride ?? file.name ?? "file";
|
|
619
|
-
if (!encrypt) {
|
|
620
|
-
validatePlainFilename(filename);
|
|
621
|
-
}
|
|
622
|
-
this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
|
|
623
|
-
let cryptoKey = null;
|
|
624
|
-
let keyB64 = null;
|
|
625
|
-
let transmittedFilename = filename;
|
|
626
|
-
if (encrypt) {
|
|
627
|
-
progress({ phase: "crypto", text: "Generating encryption key..." });
|
|
608
|
+
const internalController = signal ? null : new AbortController();
|
|
609
|
+
const effectiveSignal = signal || internalController?.signal;
|
|
610
|
+
let uploadState = "initializing";
|
|
611
|
+
let currentUploadId = null;
|
|
612
|
+
let currentBaseUrl = null;
|
|
613
|
+
const uploadPromise = (async () => {
|
|
628
614
|
try {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
615
|
+
const progress = (evt) => {
|
|
616
|
+
try {
|
|
617
|
+
if (onProgress) onProgress(evt);
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
if (!this.cryptoObj?.subtle) {
|
|
622
|
+
throw new DropgateValidationError(
|
|
623
|
+
"Web Crypto API not available (crypto.subtle)."
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const fileSizeBytes = file.size;
|
|
627
|
+
progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
|
|
628
|
+
const compat = await this.checkCompatibility({
|
|
629
|
+
host,
|
|
630
|
+
port,
|
|
631
|
+
secure,
|
|
632
|
+
timeoutMs: timeouts.serverInfoMs ?? 5e3,
|
|
633
|
+
signal: effectiveSignal
|
|
634
|
+
});
|
|
635
|
+
const { baseUrl, serverInfo } = compat;
|
|
636
|
+
progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
|
|
637
|
+
if (!compat.compatible) {
|
|
638
|
+
throw new DropgateValidationError(compat.message);
|
|
639
|
+
}
|
|
640
|
+
const filename = filenameOverride ?? file.name ?? "file";
|
|
641
|
+
if (!encrypt) {
|
|
642
|
+
validatePlainFilename(filename);
|
|
643
|
+
}
|
|
644
|
+
this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
|
|
645
|
+
let cryptoKey = null;
|
|
646
|
+
let keyB64 = null;
|
|
647
|
+
let transmittedFilename = filename;
|
|
648
|
+
if (encrypt) {
|
|
649
|
+
progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
|
|
650
|
+
try {
|
|
651
|
+
cryptoKey = await generateAesGcmKey(this.cryptoObj);
|
|
652
|
+
keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
|
|
653
|
+
transmittedFilename = await encryptFilenameToBase64(
|
|
654
|
+
this.cryptoObj,
|
|
655
|
+
filename,
|
|
656
|
+
cryptoKey
|
|
657
|
+
);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
throw new DropgateError("Failed to prepare encryption.", {
|
|
660
|
+
code: "CRYPTO_PREP_FAILED",
|
|
661
|
+
cause: err
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const totalChunks = Math.ceil(file.size / this.chunkSize);
|
|
666
|
+
const totalUploadSize = estimateTotalUploadSizeBytes(
|
|
667
|
+
file.size,
|
|
668
|
+
totalChunks,
|
|
669
|
+
encrypt
|
|
635
670
|
);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
671
|
+
progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
|
|
672
|
+
const initPayload = {
|
|
673
|
+
filename: transmittedFilename,
|
|
674
|
+
lifetime: lifetimeMs,
|
|
675
|
+
isEncrypted: Boolean(encrypt),
|
|
676
|
+
totalSize: totalUploadSize,
|
|
677
|
+
totalChunks
|
|
678
|
+
};
|
|
679
|
+
const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
|
|
680
|
+
method: "POST",
|
|
681
|
+
timeoutMs: timeouts.initMs ?? 15e3,
|
|
682
|
+
signal: effectiveSignal,
|
|
683
|
+
headers: {
|
|
684
|
+
"Content-Type": "application/json",
|
|
685
|
+
Accept: "application/json"
|
|
686
|
+
},
|
|
687
|
+
body: JSON.stringify(initPayload)
|
|
640
688
|
});
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
689
|
+
if (!initRes.res.ok) {
|
|
690
|
+
const errorJson = initRes.json;
|
|
691
|
+
const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
|
|
692
|
+
throw new DropgateProtocolError(msg, {
|
|
693
|
+
details: initRes.json || initRes.text
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
const initJson = initRes.json;
|
|
697
|
+
const uploadId = initJson?.uploadId;
|
|
698
|
+
if (!uploadId || typeof uploadId !== "string") {
|
|
699
|
+
throw new DropgateProtocolError(
|
|
700
|
+
"Server did not return a valid uploadId."
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
currentUploadId = uploadId;
|
|
704
|
+
currentBaseUrl = baseUrl;
|
|
705
|
+
uploadState = "uploading";
|
|
706
|
+
const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
|
|
707
|
+
const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
|
|
708
|
+
const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
|
|
709
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
710
|
+
if (effectiveSignal?.aborted) {
|
|
711
|
+
throw effectiveSignal.reason || new DropgateAbortError();
|
|
712
|
+
}
|
|
713
|
+
const start = i * this.chunkSize;
|
|
714
|
+
const end = Math.min(start + this.chunkSize, file.size);
|
|
715
|
+
let chunkBlob = file.slice(start, end);
|
|
716
|
+
const percentComplete = i / totalChunks * 100;
|
|
717
|
+
const processedBytes = i * this.chunkSize;
|
|
718
|
+
progress({
|
|
719
|
+
phase: "chunk",
|
|
720
|
+
text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
|
|
721
|
+
percent: percentComplete,
|
|
722
|
+
processedBytes,
|
|
723
|
+
totalBytes: fileSizeBytes,
|
|
724
|
+
chunkIndex: i,
|
|
725
|
+
totalChunks
|
|
726
|
+
});
|
|
727
|
+
const chunkBuffer = await chunkBlob.arrayBuffer();
|
|
728
|
+
let uploadBlob;
|
|
729
|
+
if (encrypt && cryptoKey) {
|
|
730
|
+
uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
|
|
731
|
+
} else {
|
|
732
|
+
uploadBlob = new Blob([chunkBuffer]);
|
|
733
|
+
}
|
|
734
|
+
if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
|
|
735
|
+
throw new DropgateValidationError(
|
|
736
|
+
"Chunk too large (client-side). Check chunk size settings."
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
const toHash = await uploadBlob.arrayBuffer();
|
|
740
|
+
const hashHex = await sha256Hex(this.cryptoObj, toHash);
|
|
741
|
+
const headers = {
|
|
742
|
+
"Content-Type": "application/octet-stream",
|
|
743
|
+
"X-Upload-ID": uploadId,
|
|
744
|
+
"X-Chunk-Index": String(i),
|
|
745
|
+
"X-Chunk-Hash": hashHex
|
|
746
|
+
};
|
|
747
|
+
const chunkUrl = `${baseUrl}/upload/chunk`;
|
|
748
|
+
await this.attemptChunkUpload(
|
|
749
|
+
chunkUrl,
|
|
750
|
+
{
|
|
751
|
+
method: "POST",
|
|
752
|
+
headers,
|
|
753
|
+
body: uploadBlob
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
retries,
|
|
757
|
+
backoffMs: baseBackoffMs,
|
|
758
|
+
maxBackoffMs,
|
|
759
|
+
timeoutMs: timeouts.chunkMs ?? 6e4,
|
|
760
|
+
signal: effectiveSignal,
|
|
761
|
+
progress,
|
|
762
|
+
chunkIndex: i,
|
|
763
|
+
totalChunks,
|
|
764
|
+
chunkSize: this.chunkSize,
|
|
765
|
+
fileSizeBytes
|
|
766
|
+
}
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
|
|
770
|
+
uploadState = "completing";
|
|
771
|
+
const completeRes = await fetchJson(
|
|
772
|
+
this.fetchFn,
|
|
773
|
+
`${baseUrl}/upload/complete`,
|
|
774
|
+
{
|
|
775
|
+
method: "POST",
|
|
776
|
+
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
777
|
+
signal: effectiveSignal,
|
|
778
|
+
headers: {
|
|
779
|
+
"Content-Type": "application/json",
|
|
780
|
+
Accept: "application/json"
|
|
781
|
+
},
|
|
782
|
+
body: JSON.stringify({ uploadId })
|
|
783
|
+
}
|
|
709
784
|
);
|
|
785
|
+
if (!completeRes.res.ok) {
|
|
786
|
+
const errorJson = completeRes.json;
|
|
787
|
+
const msg = errorJson?.error || "Finalisation failed.";
|
|
788
|
+
throw new DropgateProtocolError(msg, {
|
|
789
|
+
details: completeRes.json || completeRes.text
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
const completeJson = completeRes.json;
|
|
793
|
+
const fileId = completeJson?.id;
|
|
794
|
+
if (!fileId || typeof fileId !== "string") {
|
|
795
|
+
throw new DropgateProtocolError(
|
|
796
|
+
"Server did not return a valid file id."
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
let downloadUrl = `${baseUrl}/${fileId}`;
|
|
800
|
+
if (encrypt && keyB64) {
|
|
801
|
+
downloadUrl += `#${keyB64}`;
|
|
802
|
+
}
|
|
803
|
+
progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
|
|
804
|
+
uploadState = "completed";
|
|
805
|
+
return {
|
|
806
|
+
downloadUrl,
|
|
807
|
+
fileId,
|
|
808
|
+
uploadId,
|
|
809
|
+
baseUrl,
|
|
810
|
+
...encrypt && keyB64 ? { keyB64 } : {}
|
|
811
|
+
};
|
|
812
|
+
} catch (err) {
|
|
813
|
+
if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
|
|
814
|
+
uploadState = "cancelled";
|
|
815
|
+
onCancel?.();
|
|
816
|
+
} else {
|
|
817
|
+
uploadState = "error";
|
|
818
|
+
}
|
|
819
|
+
throw err;
|
|
710
820
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
"X-Upload-ID": uploadId,
|
|
716
|
-
"X-Chunk-Index": String(i),
|
|
717
|
-
"X-Chunk-Hash": hashHex
|
|
718
|
-
};
|
|
719
|
-
const chunkUrl = `${baseUrl}/upload/chunk`;
|
|
720
|
-
await this.attemptChunkUpload(
|
|
721
|
-
chunkUrl,
|
|
722
|
-
{
|
|
821
|
+
})();
|
|
822
|
+
const callCancelEndpoint = async (uploadId, baseUrl) => {
|
|
823
|
+
try {
|
|
824
|
+
await fetchJson(this.fetchFn, `${baseUrl}/upload/cancel`, {
|
|
723
825
|
method: "POST",
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
signal,
|
|
733
|
-
progress,
|
|
734
|
-
chunkIndex: i,
|
|
735
|
-
totalChunks
|
|
736
|
-
}
|
|
737
|
-
);
|
|
738
|
-
}
|
|
739
|
-
progress({ phase: "complete", text: "Finalising upload...", percent: 100 });
|
|
740
|
-
const completeRes = await fetchJson(
|
|
741
|
-
this.fetchFn,
|
|
742
|
-
`${baseUrl}/upload/complete`,
|
|
743
|
-
{
|
|
744
|
-
method: "POST",
|
|
745
|
-
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
746
|
-
signal,
|
|
747
|
-
headers: {
|
|
748
|
-
"Content-Type": "application/json",
|
|
749
|
-
Accept: "application/json"
|
|
750
|
-
},
|
|
751
|
-
body: JSON.stringify({ uploadId })
|
|
826
|
+
timeoutMs: 5e3,
|
|
827
|
+
headers: {
|
|
828
|
+
"Content-Type": "application/json",
|
|
829
|
+
Accept: "application/json"
|
|
830
|
+
},
|
|
831
|
+
body: JSON.stringify({ uploadId })
|
|
832
|
+
});
|
|
833
|
+
} catch {
|
|
752
834
|
}
|
|
753
|
-
|
|
754
|
-
if (!completeRes.res.ok) {
|
|
755
|
-
const errorJson = completeRes.json;
|
|
756
|
-
const msg = errorJson?.error || "Finalisation failed.";
|
|
757
|
-
throw new DropgateProtocolError(msg, {
|
|
758
|
-
details: completeRes.json || completeRes.text
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
const completeJson = completeRes.json;
|
|
762
|
-
const fileId = completeJson?.id;
|
|
763
|
-
if (!fileId || typeof fileId !== "string") {
|
|
764
|
-
throw new DropgateProtocolError(
|
|
765
|
-
"Server did not return a valid file id."
|
|
766
|
-
);
|
|
767
|
-
}
|
|
768
|
-
let downloadUrl = `${baseUrl}/${fileId}`;
|
|
769
|
-
if (encrypt && keyB64) {
|
|
770
|
-
downloadUrl += `#${keyB64}`;
|
|
771
|
-
}
|
|
772
|
-
progress({ phase: "done", text: "Upload successful!", percent: 100 });
|
|
835
|
+
};
|
|
773
836
|
return {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
837
|
+
result: uploadPromise,
|
|
838
|
+
cancel: (reason) => {
|
|
839
|
+
if (uploadState === "completed" || uploadState === "cancelled") return;
|
|
840
|
+
uploadState = "cancelled";
|
|
841
|
+
if (currentUploadId && currentBaseUrl) {
|
|
842
|
+
callCancelEndpoint(currentUploadId, currentBaseUrl).catch(() => {
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
|
|
846
|
+
},
|
|
847
|
+
getStatus: () => uploadState
|
|
779
848
|
};
|
|
780
849
|
}
|
|
781
850
|
/**
|
|
@@ -815,8 +884,20 @@ var DropgateClient = class {
|
|
|
815
884
|
if (!fileId || typeof fileId !== "string") {
|
|
816
885
|
throw new DropgateValidationError("File ID is required.");
|
|
817
886
|
}
|
|
818
|
-
|
|
819
|
-
|
|
887
|
+
progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
888
|
+
const compat = await this.checkCompatibility({
|
|
889
|
+
host,
|
|
890
|
+
port,
|
|
891
|
+
secure,
|
|
892
|
+
timeoutMs,
|
|
893
|
+
signal
|
|
894
|
+
});
|
|
895
|
+
const { baseUrl } = compat;
|
|
896
|
+
progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
897
|
+
if (!compat.compatible) {
|
|
898
|
+
throw new DropgateValidationError(compat.message);
|
|
899
|
+
}
|
|
900
|
+
progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
820
901
|
const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
|
|
821
902
|
let metadata;
|
|
822
903
|
try {
|
|
@@ -859,7 +940,7 @@ var DropgateClient = class {
|
|
|
859
940
|
if (!this.cryptoObj?.subtle) {
|
|
860
941
|
throw new DropgateValidationError("Web Crypto API not available for decryption.");
|
|
861
942
|
}
|
|
862
|
-
progress({ phase: "decrypting", text: "Preparing decryption...",
|
|
943
|
+
progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
863
944
|
try {
|
|
864
945
|
cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
|
|
865
946
|
filename = await decryptFilenameFromBase64(
|
|
@@ -877,7 +958,7 @@ var DropgateClient = class {
|
|
|
877
958
|
} else {
|
|
878
959
|
filename = metadata.filename || "file";
|
|
879
960
|
}
|
|
880
|
-
progress({ phase: "downloading", text: "Starting download...", percent: 0,
|
|
961
|
+
progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
|
|
881
962
|
const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
|
|
882
963
|
let receivedBytes = 0;
|
|
883
964
|
const dataChunks = [];
|
|
@@ -946,7 +1027,7 @@ var DropgateClient = class {
|
|
|
946
1027
|
phase: "decrypting",
|
|
947
1028
|
text: `Downloading & decrypting... (${percent}%)`,
|
|
948
1029
|
percent,
|
|
949
|
-
receivedBytes,
|
|
1030
|
+
processedBytes: receivedBytes,
|
|
950
1031
|
totalBytes
|
|
951
1032
|
});
|
|
952
1033
|
}
|
|
@@ -978,7 +1059,7 @@ var DropgateClient = class {
|
|
|
978
1059
|
phase: "downloading",
|
|
979
1060
|
text: `Downloading... (${percent}%)`,
|
|
980
1061
|
percent,
|
|
981
|
-
receivedBytes,
|
|
1062
|
+
processedBytes: receivedBytes,
|
|
982
1063
|
totalBytes
|
|
983
1064
|
});
|
|
984
1065
|
}
|
|
@@ -992,7 +1073,7 @@ var DropgateClient = class {
|
|
|
992
1073
|
} finally {
|
|
993
1074
|
downloadCleanup();
|
|
994
1075
|
}
|
|
995
|
-
progress({ phase: "complete", text: "Download complete!", percent: 100, receivedBytes, totalBytes });
|
|
1076
|
+
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
|
|
996
1077
|
let data;
|
|
997
1078
|
if (collectData && dataChunks.length > 0) {
|
|
998
1079
|
const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
@@ -1019,7 +1100,9 @@ var DropgateClient = class {
|
|
|
1019
1100
|
signal,
|
|
1020
1101
|
progress,
|
|
1021
1102
|
chunkIndex,
|
|
1022
|
-
totalChunks
|
|
1103
|
+
totalChunks,
|
|
1104
|
+
chunkSize,
|
|
1105
|
+
fileSizeBytes
|
|
1023
1106
|
} = opts;
|
|
1024
1107
|
let attemptsLeft = retries;
|
|
1025
1108
|
let currentBackoff = backoffMs;
|
|
@@ -1052,6 +1135,8 @@ var DropgateClient = class {
|
|
|
1052
1135
|
throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
|
|
1053
1136
|
}
|
|
1054
1137
|
const attemptNumber = maxRetries - attemptsLeft + 1;
|
|
1138
|
+
const processedBytes = chunkIndex * chunkSize;
|
|
1139
|
+
const percent = chunkIndex / totalChunks * 100;
|
|
1055
1140
|
let remaining = currentBackoff;
|
|
1056
1141
|
const tick = 100;
|
|
1057
1142
|
while (remaining > 0) {
|
|
@@ -1059,6 +1144,9 @@ var DropgateClient = class {
|
|
|
1059
1144
|
progress({
|
|
1060
1145
|
phase: "retry-wait",
|
|
1061
1146
|
text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
|
|
1147
|
+
percent,
|
|
1148
|
+
processedBytes,
|
|
1149
|
+
totalBytes: fileSizeBytes,
|
|
1062
1150
|
chunkIndex,
|
|
1063
1151
|
totalChunks
|
|
1064
1152
|
});
|
|
@@ -1068,6 +1156,9 @@ var DropgateClient = class {
|
|
|
1068
1156
|
progress({
|
|
1069
1157
|
phase: "retry",
|
|
1070
1158
|
text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
|
|
1159
|
+
percent,
|
|
1160
|
+
processedBytes,
|
|
1161
|
+
totalBytes: fileSizeBytes,
|
|
1071
1162
|
chunkIndex,
|
|
1072
1163
|
totalChunks
|
|
1073
1164
|
});
|
|
@@ -1090,11 +1181,11 @@ function isSecureContextForP2P(hostname, isSecureContext) {
|
|
|
1090
1181
|
return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
|
|
1091
1182
|
}
|
|
1092
1183
|
function generateP2PCode(cryptoObj) {
|
|
1093
|
-
const
|
|
1184
|
+
const crypto2 = cryptoObj || getDefaultCrypto();
|
|
1094
1185
|
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
1095
|
-
if (
|
|
1186
|
+
if (crypto2) {
|
|
1096
1187
|
const randomBytes = new Uint8Array(8);
|
|
1097
|
-
|
|
1188
|
+
crypto2.getRandomValues(randomBytes);
|
|
1098
1189
|
let letterPart = "";
|
|
1099
1190
|
for (let i = 0; i < 4; i++) {
|
|
1100
1191
|
letterPart += letters[randomBytes[i] % letters.length];
|
|
@@ -1120,8 +1211,14 @@ function isP2PCodeLike(code) {
|
|
|
1120
1211
|
}
|
|
1121
1212
|
|
|
1122
1213
|
// src/p2p/helpers.ts
|
|
1123
|
-
function
|
|
1124
|
-
|
|
1214
|
+
function resolvePeerConfig(userConfig, serverCaps) {
|
|
1215
|
+
return {
|
|
1216
|
+
path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
|
|
1217
|
+
iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
function buildPeerOptions(config = {}) {
|
|
1221
|
+
const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
|
|
1125
1222
|
const peerOpts = {
|
|
1126
1223
|
host,
|
|
1127
1224
|
path: peerjsPath,
|
|
@@ -1163,6 +1260,12 @@ async function createPeerWithRetries(opts) {
|
|
|
1163
1260
|
}
|
|
1164
1261
|
|
|
1165
1262
|
// src/p2p/send.ts
|
|
1263
|
+
function generateSessionId() {
|
|
1264
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
1265
|
+
return crypto.randomUUID();
|
|
1266
|
+
}
|
|
1267
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
1268
|
+
}
|
|
1166
1269
|
async function startP2PSend(opts) {
|
|
1167
1270
|
const {
|
|
1168
1271
|
file,
|
|
@@ -1177,15 +1280,17 @@ async function startP2PSend(opts) {
|
|
|
1177
1280
|
cryptoObj,
|
|
1178
1281
|
maxAttempts = 4,
|
|
1179
1282
|
chunkSize = 256 * 1024,
|
|
1180
|
-
readyTimeoutMs = 8e3,
|
|
1181
1283
|
endAckTimeoutMs = 15e3,
|
|
1182
1284
|
bufferHighWaterMark = 8 * 1024 * 1024,
|
|
1183
1285
|
bufferLowWaterMark = 2 * 1024 * 1024,
|
|
1286
|
+
heartbeatIntervalMs = 5e3,
|
|
1184
1287
|
onCode,
|
|
1185
1288
|
onStatus,
|
|
1186
1289
|
onProgress,
|
|
1187
1290
|
onComplete,
|
|
1188
|
-
onError
|
|
1291
|
+
onError,
|
|
1292
|
+
onDisconnect,
|
|
1293
|
+
onCancel
|
|
1189
1294
|
} = opts;
|
|
1190
1295
|
if (!file) {
|
|
1191
1296
|
throw new DropgateValidationError("File is missing.");
|
|
@@ -1199,8 +1304,10 @@ async function startP2PSend(opts) {
|
|
|
1199
1304
|
if (serverInfo && !p2pCaps?.enabled) {
|
|
1200
1305
|
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1201
1306
|
}
|
|
1202
|
-
const finalPath
|
|
1203
|
-
|
|
1307
|
+
const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
|
|
1308
|
+
{ peerjsPath, iceServers },
|
|
1309
|
+
p2pCaps
|
|
1310
|
+
);
|
|
1204
1311
|
const peerOpts = buildPeerOptions({
|
|
1205
1312
|
host,
|
|
1206
1313
|
port,
|
|
@@ -1217,18 +1324,37 @@ async function startP2PSend(opts) {
|
|
|
1217
1324
|
buildPeer,
|
|
1218
1325
|
onCode
|
|
1219
1326
|
});
|
|
1220
|
-
|
|
1327
|
+
const sessionId = generateSessionId();
|
|
1328
|
+
let state = "listening";
|
|
1221
1329
|
let activeConn = null;
|
|
1222
|
-
let
|
|
1223
|
-
let
|
|
1330
|
+
let sentBytes = 0;
|
|
1331
|
+
let heartbeatTimer = null;
|
|
1224
1332
|
const reportProgress = (data) => {
|
|
1225
1333
|
const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
|
|
1226
1334
|
const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
|
|
1227
1335
|
const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
|
|
1228
|
-
onProgress?.({
|
|
1336
|
+
onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
|
|
1229
1337
|
};
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1338
|
+
const safeError = (err) => {
|
|
1339
|
+
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1340
|
+
state = "closed";
|
|
1341
|
+
onError?.(err);
|
|
1342
|
+
cleanup();
|
|
1343
|
+
};
|
|
1344
|
+
const safeComplete = () => {
|
|
1345
|
+
if (state !== "finishing") return;
|
|
1346
|
+
state = "completed";
|
|
1347
|
+
onComplete?.();
|
|
1348
|
+
cleanup();
|
|
1349
|
+
};
|
|
1350
|
+
const cleanup = () => {
|
|
1351
|
+
if (heartbeatTimer) {
|
|
1352
|
+
clearInterval(heartbeatTimer);
|
|
1353
|
+
heartbeatTimer = null;
|
|
1354
|
+
}
|
|
1355
|
+
if (typeof window !== "undefined") {
|
|
1356
|
+
window.removeEventListener("beforeunload", handleUnload);
|
|
1357
|
+
}
|
|
1232
1358
|
try {
|
|
1233
1359
|
activeConn?.close();
|
|
1234
1360
|
} catch {
|
|
@@ -1238,21 +1364,69 @@ async function startP2PSend(opts) {
|
|
|
1238
1364
|
} catch {
|
|
1239
1365
|
}
|
|
1240
1366
|
};
|
|
1367
|
+
const handleUnload = () => {
|
|
1368
|
+
try {
|
|
1369
|
+
activeConn?.send({ t: "error", message: "Sender closed the connection." });
|
|
1370
|
+
} catch {
|
|
1371
|
+
}
|
|
1372
|
+
stop();
|
|
1373
|
+
};
|
|
1374
|
+
if (typeof window !== "undefined") {
|
|
1375
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
1376
|
+
}
|
|
1377
|
+
const stop = () => {
|
|
1378
|
+
if (state === "closed" || state === "cancelled") return;
|
|
1379
|
+
const wasActive = state === "transferring" || state === "finishing";
|
|
1380
|
+
state = "cancelled";
|
|
1381
|
+
try {
|
|
1382
|
+
if (activeConn && activeConn.open) {
|
|
1383
|
+
activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
|
|
1384
|
+
}
|
|
1385
|
+
} catch {
|
|
1386
|
+
}
|
|
1387
|
+
if (wasActive && onCancel) {
|
|
1388
|
+
onCancel({ cancelledBy: "sender" });
|
|
1389
|
+
}
|
|
1390
|
+
cleanup();
|
|
1391
|
+
};
|
|
1392
|
+
const isStopped = () => state === "closed" || state === "cancelled";
|
|
1241
1393
|
peer.on("connection", (conn) => {
|
|
1242
|
-
if (
|
|
1394
|
+
if (state === "closed") return;
|
|
1243
1395
|
if (activeConn) {
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1396
|
+
const isOldConnOpen = activeConn.open !== false;
|
|
1397
|
+
if (isOldConnOpen && state === "transferring") {
|
|
1398
|
+
try {
|
|
1399
|
+
conn.send({ t: "error", message: "Transfer already in progress." });
|
|
1400
|
+
} catch {
|
|
1401
|
+
}
|
|
1402
|
+
try {
|
|
1403
|
+
conn.close();
|
|
1404
|
+
} catch {
|
|
1405
|
+
}
|
|
1406
|
+
return;
|
|
1407
|
+
} else if (!isOldConnOpen) {
|
|
1408
|
+
try {
|
|
1409
|
+
activeConn.close();
|
|
1410
|
+
} catch {
|
|
1411
|
+
}
|
|
1412
|
+
activeConn = null;
|
|
1413
|
+
state = "listening";
|
|
1414
|
+
sentBytes = 0;
|
|
1415
|
+
} else {
|
|
1416
|
+
try {
|
|
1417
|
+
conn.send({ t: "error", message: "Another receiver is already connected." });
|
|
1418
|
+
} catch {
|
|
1419
|
+
}
|
|
1420
|
+
try {
|
|
1421
|
+
conn.close();
|
|
1422
|
+
} catch {
|
|
1423
|
+
}
|
|
1424
|
+
return;
|
|
1251
1425
|
}
|
|
1252
|
-
return;
|
|
1253
1426
|
}
|
|
1254
1427
|
activeConn = conn;
|
|
1255
|
-
|
|
1428
|
+
state = "negotiating";
|
|
1429
|
+
onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
|
|
1256
1430
|
let readyResolve = null;
|
|
1257
1431
|
let ackResolve = null;
|
|
1258
1432
|
const readyPromise = new Promise((resolve) => {
|
|
@@ -1268,6 +1442,7 @@ async function startP2PSend(opts) {
|
|
|
1268
1442
|
const msg = data;
|
|
1269
1443
|
if (!msg.t) return;
|
|
1270
1444
|
if (msg.t === "ready") {
|
|
1445
|
+
onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
|
|
1271
1446
|
readyResolve?.();
|
|
1272
1447
|
return;
|
|
1273
1448
|
}
|
|
@@ -1279,22 +1454,30 @@ async function startP2PSend(opts) {
|
|
|
1279
1454
|
ackResolve?.(msg);
|
|
1280
1455
|
return;
|
|
1281
1456
|
}
|
|
1457
|
+
if (msg.t === "pong") {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1282
1460
|
if (msg.t === "error") {
|
|
1283
|
-
|
|
1284
|
-
|
|
1461
|
+
safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (msg.t === "cancelled") {
|
|
1465
|
+
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
1466
|
+
state = "cancelled";
|
|
1467
|
+
onCancel?.({ cancelledBy: "receiver", message: msg.message });
|
|
1468
|
+
cleanup();
|
|
1285
1469
|
}
|
|
1286
1470
|
});
|
|
1287
1471
|
conn.on("open", async () => {
|
|
1288
1472
|
try {
|
|
1289
|
-
|
|
1290
|
-
if (stopped) return;
|
|
1473
|
+
if (isStopped()) return;
|
|
1291
1474
|
conn.send({
|
|
1292
1475
|
t: "meta",
|
|
1476
|
+
sessionId,
|
|
1293
1477
|
name: file.name,
|
|
1294
1478
|
size: file.size,
|
|
1295
1479
|
mime: file.type || "application/octet-stream"
|
|
1296
1480
|
});
|
|
1297
|
-
let sent = 0;
|
|
1298
1481
|
const total = file.size;
|
|
1299
1482
|
const dc = conn._dc;
|
|
1300
1483
|
if (dc && Number.isFinite(bufferLowWaterMark)) {
|
|
@@ -1303,13 +1486,26 @@ async function startP2PSend(opts) {
|
|
|
1303
1486
|
} catch {
|
|
1304
1487
|
}
|
|
1305
1488
|
}
|
|
1306
|
-
await
|
|
1489
|
+
await readyPromise;
|
|
1490
|
+
if (isStopped()) return;
|
|
1491
|
+
if (heartbeatIntervalMs > 0) {
|
|
1492
|
+
heartbeatTimer = setInterval(() => {
|
|
1493
|
+
if (state === "transferring" || state === "finishing") {
|
|
1494
|
+
try {
|
|
1495
|
+
conn.send({ t: "ping" });
|
|
1496
|
+
} catch {
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}, heartbeatIntervalMs);
|
|
1500
|
+
}
|
|
1501
|
+
state = "transferring";
|
|
1307
1502
|
for (let offset = 0; offset < total; offset += chunkSize) {
|
|
1308
|
-
if (
|
|
1503
|
+
if (isStopped()) return;
|
|
1309
1504
|
const slice = file.slice(offset, offset + chunkSize);
|
|
1310
1505
|
const buf = await slice.arrayBuffer();
|
|
1506
|
+
if (isStopped()) return;
|
|
1311
1507
|
conn.send(buf);
|
|
1312
|
-
|
|
1508
|
+
sentBytes += buf.byteLength;
|
|
1313
1509
|
if (dc) {
|
|
1314
1510
|
while (dc.bufferedAmount > bufferHighWaterMark) {
|
|
1315
1511
|
await new Promise((resolve) => {
|
|
@@ -1329,13 +1525,15 @@ async function startP2PSend(opts) {
|
|
|
1329
1525
|
}
|
|
1330
1526
|
}
|
|
1331
1527
|
}
|
|
1332
|
-
if (
|
|
1528
|
+
if (isStopped()) return;
|
|
1529
|
+
state = "finishing";
|
|
1333
1530
|
conn.send({ t: "end" });
|
|
1334
1531
|
const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
|
|
1335
1532
|
const ackResult = await Promise.race([
|
|
1336
1533
|
ackPromise,
|
|
1337
1534
|
sleep(ackTimeoutMs || 15e3).catch(() => null)
|
|
1338
1535
|
]);
|
|
1536
|
+
if (isStopped()) return;
|
|
1339
1537
|
if (!ackResult || typeof ackResult !== "object") {
|
|
1340
1538
|
throw new DropgateNetworkError("Receiver did not confirm completion.");
|
|
1341
1539
|
}
|
|
@@ -1346,29 +1544,43 @@ async function startP2PSend(opts) {
|
|
|
1346
1544
|
throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
|
|
1347
1545
|
}
|
|
1348
1546
|
reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
|
|
1349
|
-
|
|
1350
|
-
transferActive = false;
|
|
1351
|
-
onComplete?.();
|
|
1352
|
-
stop();
|
|
1547
|
+
safeComplete();
|
|
1353
1548
|
} catch (err) {
|
|
1354
|
-
|
|
1355
|
-
stop();
|
|
1549
|
+
safeError(err);
|
|
1356
1550
|
}
|
|
1357
1551
|
});
|
|
1358
1552
|
conn.on("error", (err) => {
|
|
1359
|
-
|
|
1360
|
-
stop();
|
|
1553
|
+
safeError(err);
|
|
1361
1554
|
});
|
|
1362
1555
|
conn.on("close", () => {
|
|
1363
|
-
if (
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1556
|
+
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
1557
|
+
cleanup();
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (state === "transferring" || state === "finishing") {
|
|
1561
|
+
state = "cancelled";
|
|
1562
|
+
onCancel?.({ cancelledBy: "receiver" });
|
|
1563
|
+
cleanup();
|
|
1564
|
+
} else {
|
|
1565
|
+
activeConn = null;
|
|
1566
|
+
state = "listening";
|
|
1567
|
+
sentBytes = 0;
|
|
1568
|
+
onDisconnect?.();
|
|
1367
1569
|
}
|
|
1368
|
-
stop();
|
|
1369
1570
|
});
|
|
1370
1571
|
});
|
|
1371
|
-
return {
|
|
1572
|
+
return {
|
|
1573
|
+
peer,
|
|
1574
|
+
code,
|
|
1575
|
+
sessionId,
|
|
1576
|
+
stop,
|
|
1577
|
+
getStatus: () => state,
|
|
1578
|
+
getBytesSent: () => sentBytes,
|
|
1579
|
+
getConnectedPeerId: () => {
|
|
1580
|
+
if (!activeConn) return null;
|
|
1581
|
+
return activeConn.peer || null;
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1372
1584
|
}
|
|
1373
1585
|
|
|
1374
1586
|
// src/p2p/receive.ts
|
|
@@ -1382,13 +1594,16 @@ async function startP2PReceive(opts) {
|
|
|
1382
1594
|
peerjsPath,
|
|
1383
1595
|
secure = false,
|
|
1384
1596
|
iceServers,
|
|
1597
|
+
autoReady = true,
|
|
1598
|
+
watchdogTimeoutMs = 15e3,
|
|
1385
1599
|
onStatus,
|
|
1386
1600
|
onMeta,
|
|
1387
1601
|
onData,
|
|
1388
1602
|
onProgress,
|
|
1389
1603
|
onComplete,
|
|
1390
1604
|
onError,
|
|
1391
|
-
onDisconnect
|
|
1605
|
+
onDisconnect,
|
|
1606
|
+
onCancel
|
|
1392
1607
|
} = opts;
|
|
1393
1608
|
if (!code) {
|
|
1394
1609
|
throw new DropgateValidationError("No sharing code was provided.");
|
|
@@ -1406,8 +1621,10 @@ async function startP2PReceive(opts) {
|
|
|
1406
1621
|
if (!isP2PCodeLike(normalizedCode)) {
|
|
1407
1622
|
throw new DropgateValidationError("Invalid direct transfer code.");
|
|
1408
1623
|
}
|
|
1409
|
-
const finalPath
|
|
1410
|
-
|
|
1624
|
+
const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
|
|
1625
|
+
{ peerjsPath, iceServers },
|
|
1626
|
+
p2pCaps
|
|
1627
|
+
);
|
|
1411
1628
|
const peerOpts = buildPeerOptions({
|
|
1412
1629
|
host,
|
|
1413
1630
|
port,
|
|
@@ -1416,44 +1633,137 @@ async function startP2PReceive(opts) {
|
|
|
1416
1633
|
iceServers: finalIceServers
|
|
1417
1634
|
});
|
|
1418
1635
|
const peer = new Peer(void 0, peerOpts);
|
|
1636
|
+
let state = "initializing";
|
|
1419
1637
|
let total = 0;
|
|
1420
1638
|
let received = 0;
|
|
1639
|
+
let currentSessionId = null;
|
|
1421
1640
|
let lastProgressSentAt = 0;
|
|
1422
1641
|
const progressIntervalMs = 120;
|
|
1423
1642
|
let writeQueue = Promise.resolve();
|
|
1424
|
-
|
|
1643
|
+
let watchdogTimer = null;
|
|
1644
|
+
let activeConn = null;
|
|
1645
|
+
const resetWatchdog = () => {
|
|
1646
|
+
if (watchdogTimeoutMs <= 0) return;
|
|
1647
|
+
if (watchdogTimer) {
|
|
1648
|
+
clearTimeout(watchdogTimer);
|
|
1649
|
+
}
|
|
1650
|
+
watchdogTimer = setTimeout(() => {
|
|
1651
|
+
if (state === "transferring") {
|
|
1652
|
+
safeError(new DropgateNetworkError("Connection timed out (no data received)."));
|
|
1653
|
+
}
|
|
1654
|
+
}, watchdogTimeoutMs);
|
|
1655
|
+
};
|
|
1656
|
+
const clearWatchdog = () => {
|
|
1657
|
+
if (watchdogTimer) {
|
|
1658
|
+
clearTimeout(watchdogTimer);
|
|
1659
|
+
watchdogTimer = null;
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
const safeError = (err) => {
|
|
1663
|
+
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1664
|
+
state = "closed";
|
|
1665
|
+
onError?.(err);
|
|
1666
|
+
cleanup();
|
|
1667
|
+
};
|
|
1668
|
+
const safeComplete = (completeData) => {
|
|
1669
|
+
if (state !== "transferring") return;
|
|
1670
|
+
state = "completed";
|
|
1671
|
+
onComplete?.(completeData);
|
|
1672
|
+
cleanup();
|
|
1673
|
+
};
|
|
1674
|
+
const cleanup = () => {
|
|
1675
|
+
clearWatchdog();
|
|
1676
|
+
if (typeof window !== "undefined") {
|
|
1677
|
+
window.removeEventListener("beforeunload", handleUnload);
|
|
1678
|
+
}
|
|
1425
1679
|
try {
|
|
1426
1680
|
peer.destroy();
|
|
1427
1681
|
} catch {
|
|
1428
1682
|
}
|
|
1429
1683
|
};
|
|
1430
|
-
|
|
1431
|
-
|
|
1684
|
+
const handleUnload = () => {
|
|
1685
|
+
try {
|
|
1686
|
+
activeConn?.send({ t: "error", message: "Receiver closed the connection." });
|
|
1687
|
+
} catch {
|
|
1688
|
+
}
|
|
1432
1689
|
stop();
|
|
1690
|
+
};
|
|
1691
|
+
if (typeof window !== "undefined") {
|
|
1692
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
1693
|
+
}
|
|
1694
|
+
const stop = () => {
|
|
1695
|
+
if (state === "closed" || state === "cancelled") return;
|
|
1696
|
+
const wasActive = state === "transferring";
|
|
1697
|
+
state = "cancelled";
|
|
1698
|
+
try {
|
|
1699
|
+
if (activeConn && activeConn.open) {
|
|
1700
|
+
activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
|
|
1701
|
+
}
|
|
1702
|
+
} catch {
|
|
1703
|
+
}
|
|
1704
|
+
if (wasActive && onCancel) {
|
|
1705
|
+
onCancel({ cancelledBy: "receiver" });
|
|
1706
|
+
}
|
|
1707
|
+
cleanup();
|
|
1708
|
+
};
|
|
1709
|
+
peer.on("error", (err) => {
|
|
1710
|
+
safeError(err);
|
|
1433
1711
|
});
|
|
1434
1712
|
peer.on("open", () => {
|
|
1713
|
+
state = "connecting";
|
|
1435
1714
|
const conn = peer.connect(normalizedCode, { reliable: true });
|
|
1715
|
+
activeConn = conn;
|
|
1436
1716
|
conn.on("open", () => {
|
|
1717
|
+
state = "negotiating";
|
|
1437
1718
|
onStatus?.({ phase: "connected", message: "Waiting for file details..." });
|
|
1438
1719
|
});
|
|
1439
1720
|
conn.on("data", async (data) => {
|
|
1440
1721
|
try {
|
|
1722
|
+
resetWatchdog();
|
|
1441
1723
|
if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
|
|
1442
1724
|
const msg = data;
|
|
1443
1725
|
if (msg.t === "meta") {
|
|
1726
|
+
if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
|
|
1727
|
+
try {
|
|
1728
|
+
conn.send({ t: "error", message: "Busy with another session." });
|
|
1729
|
+
} catch {
|
|
1730
|
+
}
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (msg.sessionId) {
|
|
1734
|
+
currentSessionId = msg.sessionId;
|
|
1735
|
+
}
|
|
1444
1736
|
const name = String(msg.name || "file");
|
|
1445
1737
|
total = Number(msg.size) || 0;
|
|
1446
1738
|
received = 0;
|
|
1447
1739
|
writeQueue = Promise.resolve();
|
|
1448
|
-
|
|
1449
|
-
|
|
1740
|
+
const sendReady = () => {
|
|
1741
|
+
state = "transferring";
|
|
1742
|
+
resetWatchdog();
|
|
1743
|
+
try {
|
|
1744
|
+
conn.send({ t: "ready" });
|
|
1745
|
+
} catch {
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
if (autoReady) {
|
|
1749
|
+
onMeta?.({ name, total });
|
|
1750
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1751
|
+
sendReady();
|
|
1752
|
+
} else {
|
|
1753
|
+
onMeta?.({ name, total, sendReady });
|
|
1754
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1755
|
+
}
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
if (msg.t === "ping") {
|
|
1450
1759
|
try {
|
|
1451
|
-
conn.send({ t: "
|
|
1760
|
+
conn.send({ t: "pong" });
|
|
1452
1761
|
} catch {
|
|
1453
1762
|
}
|
|
1454
1763
|
return;
|
|
1455
1764
|
}
|
|
1456
1765
|
if (msg.t === "end") {
|
|
1766
|
+
clearWatchdog();
|
|
1457
1767
|
await writeQueue;
|
|
1458
1768
|
if (total && received < total) {
|
|
1459
1769
|
const err = new DropgateNetworkError(
|
|
@@ -1465,16 +1775,23 @@ async function startP2PReceive(opts) {
|
|
|
1465
1775
|
}
|
|
1466
1776
|
throw err;
|
|
1467
1777
|
}
|
|
1468
|
-
onComplete?.({ received, total });
|
|
1469
1778
|
try {
|
|
1470
1779
|
conn.send({ t: "ack", phase: "end", received, total });
|
|
1471
1780
|
} catch {
|
|
1472
1781
|
}
|
|
1782
|
+
safeComplete({ received, total });
|
|
1473
1783
|
return;
|
|
1474
1784
|
}
|
|
1475
1785
|
if (msg.t === "error") {
|
|
1476
1786
|
throw new DropgateNetworkError(msg.message || "Sender reported an error.");
|
|
1477
1787
|
}
|
|
1788
|
+
if (msg.t === "cancelled") {
|
|
1789
|
+
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
1790
|
+
state = "cancelled";
|
|
1791
|
+
onCancel?.({ cancelledBy: "sender", message: msg.message });
|
|
1792
|
+
cleanup();
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1478
1795
|
return;
|
|
1479
1796
|
}
|
|
1480
1797
|
let bufPromise;
|
|
@@ -1496,7 +1813,7 @@ async function startP2PReceive(opts) {
|
|
|
1496
1813
|
}
|
|
1497
1814
|
received += buf.byteLength;
|
|
1498
1815
|
const percent = total ? Math.min(100, received / total * 100) : 0;
|
|
1499
|
-
onProgress?.({ received, total, percent });
|
|
1816
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent });
|
|
1500
1817
|
const now = Date.now();
|
|
1501
1818
|
if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
|
|
1502
1819
|
lastProgressSentAt = now;
|
|
@@ -1513,21 +1830,38 @@ async function startP2PReceive(opts) {
|
|
|
1513
1830
|
});
|
|
1514
1831
|
} catch {
|
|
1515
1832
|
}
|
|
1516
|
-
|
|
1517
|
-
stop();
|
|
1833
|
+
safeError(err);
|
|
1518
1834
|
});
|
|
1519
1835
|
} catch (err) {
|
|
1520
|
-
|
|
1521
|
-
stop();
|
|
1836
|
+
safeError(err);
|
|
1522
1837
|
}
|
|
1523
1838
|
});
|
|
1524
1839
|
conn.on("close", () => {
|
|
1525
|
-
if (
|
|
1840
|
+
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
1841
|
+
cleanup();
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
if (state === "transferring") {
|
|
1845
|
+
state = "cancelled";
|
|
1846
|
+
onCancel?.({ cancelledBy: "sender" });
|
|
1847
|
+
cleanup();
|
|
1848
|
+
} else if (state === "negotiating") {
|
|
1849
|
+
state = "closed";
|
|
1850
|
+
cleanup();
|
|
1526
1851
|
onDisconnect?.();
|
|
1852
|
+
} else {
|
|
1853
|
+
safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
|
|
1527
1854
|
}
|
|
1528
1855
|
});
|
|
1529
1856
|
});
|
|
1530
|
-
return {
|
|
1857
|
+
return {
|
|
1858
|
+
peer,
|
|
1859
|
+
stop,
|
|
1860
|
+
getStatus: () => state,
|
|
1861
|
+
getBytesReceived: () => received,
|
|
1862
|
+
getTotalBytes: () => total,
|
|
1863
|
+
getSessionId: () => currentSessionId
|
|
1864
|
+
};
|
|
1531
1865
|
}
|
|
1532
1866
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1533
1867
|
0 && (module.exports = {
|
|
@@ -1560,6 +1894,7 @@ async function startP2PReceive(opts) {
|
|
|
1560
1894
|
getDefaultBase64,
|
|
1561
1895
|
getDefaultCrypto,
|
|
1562
1896
|
getDefaultFetch,
|
|
1897
|
+
getServerInfo,
|
|
1563
1898
|
importKeyFromBase64,
|
|
1564
1899
|
isLocalhostHostname,
|
|
1565
1900
|
isP2PCodeLike,
|
|
@@ -1568,6 +1903,7 @@ async function startP2PReceive(opts) {
|
|
|
1568
1903
|
makeAbortSignal,
|
|
1569
1904
|
parseSemverMajorMinor,
|
|
1570
1905
|
parseServerUrl,
|
|
1906
|
+
resolvePeerConfig,
|
|
1571
1907
|
sha256Hex,
|
|
1572
1908
|
sleep,
|
|
1573
1909
|
startP2PReceive,
|