@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.js
CHANGED
|
@@ -309,6 +309,37 @@ function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
|
|
|
309
309
|
if (!isEncrypted) return base;
|
|
310
310
|
return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
|
|
311
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.");
|
|
317
|
+
}
|
|
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 };
|
|
332
|
+
}
|
|
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
|
+
}
|
|
342
|
+
}
|
|
312
343
|
var DropgateClient = class {
|
|
313
344
|
/**
|
|
314
345
|
* Create a new DropgateClient instance.
|
|
@@ -336,40 +367,6 @@ var DropgateClient = class {
|
|
|
336
367
|
this.base64 = opts.base64 || getDefaultBase64();
|
|
337
368
|
this.logger = opts.logger || null;
|
|
338
369
|
}
|
|
339
|
-
/**
|
|
340
|
-
* Fetch server information from the /api/info endpoint.
|
|
341
|
-
* @param opts - Server target and request options.
|
|
342
|
-
* @returns The server base URL and server info object.
|
|
343
|
-
* @throws {DropgateNetworkError} If the server cannot be reached.
|
|
344
|
-
* @throws {DropgateProtocolError} If the server returns an invalid response.
|
|
345
|
-
*/
|
|
346
|
-
async getServerInfo(opts) {
|
|
347
|
-
const { host, port, secure, timeoutMs = 5e3, signal } = opts;
|
|
348
|
-
const baseUrl = buildBaseUrl({ host, port, secure });
|
|
349
|
-
try {
|
|
350
|
-
const { res, json } = await fetchJson(
|
|
351
|
-
this.fetchFn,
|
|
352
|
-
`${baseUrl}/api/info`,
|
|
353
|
-
{
|
|
354
|
-
method: "GET",
|
|
355
|
-
timeoutMs,
|
|
356
|
-
signal,
|
|
357
|
-
headers: { Accept: "application/json" }
|
|
358
|
-
}
|
|
359
|
-
);
|
|
360
|
-
if (res.ok && json && typeof json === "object" && "version" in json) {
|
|
361
|
-
return { baseUrl, serverInfo: json };
|
|
362
|
-
}
|
|
363
|
-
throw new DropgateProtocolError(
|
|
364
|
-
`Server info request failed (status ${res.status}).`
|
|
365
|
-
);
|
|
366
|
-
} catch (err) {
|
|
367
|
-
if (err instanceof DropgateError) throw err;
|
|
368
|
-
throw new DropgateNetworkError("Could not reach server /api/info.", {
|
|
369
|
-
cause: err
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
370
|
/**
|
|
374
371
|
* Resolve a user-entered sharing code or URL via the server.
|
|
375
372
|
* @param value - The sharing code or URL to resolve.
|
|
@@ -378,8 +375,12 @@ var DropgateClient = class {
|
|
|
378
375
|
* @throws {DropgateProtocolError} If the share lookup fails.
|
|
379
376
|
*/
|
|
380
377
|
async resolveShareTarget(value, opts) {
|
|
381
|
-
const {
|
|
382
|
-
const
|
|
378
|
+
const { timeoutMs = 5e3, signal } = opts;
|
|
379
|
+
const compat = await this.checkCompatibility(opts);
|
|
380
|
+
if (!compat.compatible) {
|
|
381
|
+
throw new DropgateValidationError(compat.message);
|
|
382
|
+
}
|
|
383
|
+
const { baseUrl } = compat;
|
|
383
384
|
const { res, json } = await fetchJson(
|
|
384
385
|
this.fetchFn,
|
|
385
386
|
`${baseUrl}/api/resolve`,
|
|
@@ -402,10 +403,25 @@ var DropgateClient = class {
|
|
|
402
403
|
}
|
|
403
404
|
/**
|
|
404
405
|
* Check version compatibility between this client and a server.
|
|
405
|
-
*
|
|
406
|
-
* @
|
|
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.
|
|
407
411
|
*/
|
|
408
|
-
checkCompatibility(
|
|
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
|
|
423
|
+
});
|
|
424
|
+
}
|
|
409
425
|
const serverVersion = String(serverInfo?.version || "0.0.0");
|
|
410
426
|
const clientVersion = String(this.clientVersion || "0.0.0");
|
|
411
427
|
const c = parseSemverMajorMinor(clientVersion);
|
|
@@ -415,7 +431,9 @@ var DropgateClient = class {
|
|
|
415
431
|
compatible: false,
|
|
416
432
|
clientVersion,
|
|
417
433
|
serverVersion,
|
|
418
|
-
message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}
|
|
434
|
+
message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
|
|
435
|
+
serverInfo,
|
|
436
|
+
baseUrl
|
|
419
437
|
};
|
|
420
438
|
}
|
|
421
439
|
if (c.minor > s.minor) {
|
|
@@ -423,14 +441,18 @@ var DropgateClient = class {
|
|
|
423
441
|
compatible: true,
|
|
424
442
|
clientVersion,
|
|
425
443
|
serverVersion,
|
|
426
|
-
message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work
|
|
444
|
+
message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`,
|
|
445
|
+
serverInfo,
|
|
446
|
+
baseUrl
|
|
427
447
|
};
|
|
428
448
|
}
|
|
429
449
|
return {
|
|
430
450
|
compatible: true,
|
|
431
451
|
clientVersion,
|
|
432
452
|
serverVersion,
|
|
433
|
-
message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}
|
|
453
|
+
message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`,
|
|
454
|
+
serverInfo,
|
|
455
|
+
baseUrl
|
|
434
456
|
};
|
|
435
457
|
}
|
|
436
458
|
/**
|
|
@@ -509,206 +531,251 @@ var DropgateClient = class {
|
|
|
509
531
|
encrypt,
|
|
510
532
|
filenameOverride,
|
|
511
533
|
onProgress,
|
|
534
|
+
onCancel,
|
|
512
535
|
signal,
|
|
513
536
|
timeouts = {},
|
|
514
537
|
retry = {}
|
|
515
538
|
} = opts;
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (!this.cryptoObj?.subtle) {
|
|
523
|
-
throw new DropgateValidationError(
|
|
524
|
-
"Web Crypto API not available (crypto.subtle)."
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
progress({ phase: "server-info", text: "Checking server..." });
|
|
528
|
-
let baseUrl;
|
|
529
|
-
let serverInfo;
|
|
530
|
-
try {
|
|
531
|
-
const res = await this.getServerInfo({
|
|
532
|
-
host,
|
|
533
|
-
port,
|
|
534
|
-
secure,
|
|
535
|
-
timeoutMs: timeouts.serverInfoMs ?? 5e3,
|
|
536
|
-
signal
|
|
537
|
-
});
|
|
538
|
-
baseUrl = res.baseUrl;
|
|
539
|
-
serverInfo = res.serverInfo;
|
|
540
|
-
} catch (err) {
|
|
541
|
-
if (err instanceof DropgateError) throw err;
|
|
542
|
-
throw new DropgateNetworkError("Could not connect to the server.", {
|
|
543
|
-
cause: err
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
const compat = this.checkCompatibility(serverInfo);
|
|
547
|
-
progress({ phase: "server-compat", text: compat.message });
|
|
548
|
-
if (!compat.compatible) {
|
|
549
|
-
throw new DropgateValidationError(compat.message);
|
|
550
|
-
}
|
|
551
|
-
const filename = filenameOverride ?? file.name ?? "file";
|
|
552
|
-
if (!encrypt) {
|
|
553
|
-
validatePlainFilename(filename);
|
|
554
|
-
}
|
|
555
|
-
this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
|
|
556
|
-
let cryptoKey = null;
|
|
557
|
-
let keyB64 = null;
|
|
558
|
-
let transmittedFilename = filename;
|
|
559
|
-
if (encrypt) {
|
|
560
|
-
progress({ phase: "crypto", text: "Generating encryption key..." });
|
|
539
|
+
const internalController = signal ? null : new AbortController();
|
|
540
|
+
const effectiveSignal = signal || internalController?.signal;
|
|
541
|
+
let uploadState = "initializing";
|
|
542
|
+
let currentUploadId = null;
|
|
543
|
+
let currentBaseUrl = null;
|
|
544
|
+
const uploadPromise = (async () => {
|
|
561
545
|
try {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
546
|
+
const progress = (evt) => {
|
|
547
|
+
try {
|
|
548
|
+
if (onProgress) onProgress(evt);
|
|
549
|
+
} catch {
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
if (!this.cryptoObj?.subtle) {
|
|
553
|
+
throw new DropgateValidationError(
|
|
554
|
+
"Web Crypto API not available (crypto.subtle)."
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
const fileSizeBytes = file.size;
|
|
558
|
+
progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
|
|
559
|
+
const compat = await this.checkCompatibility({
|
|
560
|
+
host,
|
|
561
|
+
port,
|
|
562
|
+
secure,
|
|
563
|
+
timeoutMs: timeouts.serverInfoMs ?? 5e3,
|
|
564
|
+
signal: effectiveSignal
|
|
565
|
+
});
|
|
566
|
+
const { baseUrl, serverInfo } = compat;
|
|
567
|
+
progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
|
|
568
|
+
if (!compat.compatible) {
|
|
569
|
+
throw new DropgateValidationError(compat.message);
|
|
570
|
+
}
|
|
571
|
+
const filename = filenameOverride ?? file.name ?? "file";
|
|
572
|
+
if (!encrypt) {
|
|
573
|
+
validatePlainFilename(filename);
|
|
574
|
+
}
|
|
575
|
+
this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
|
|
576
|
+
let cryptoKey = null;
|
|
577
|
+
let keyB64 = null;
|
|
578
|
+
let transmittedFilename = filename;
|
|
579
|
+
if (encrypt) {
|
|
580
|
+
progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
|
|
581
|
+
try {
|
|
582
|
+
cryptoKey = await generateAesGcmKey(this.cryptoObj);
|
|
583
|
+
keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
|
|
584
|
+
transmittedFilename = await encryptFilenameToBase64(
|
|
585
|
+
this.cryptoObj,
|
|
586
|
+
filename,
|
|
587
|
+
cryptoKey
|
|
588
|
+
);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
throw new DropgateError("Failed to prepare encryption.", {
|
|
591
|
+
code: "CRYPTO_PREP_FAILED",
|
|
592
|
+
cause: err
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const totalChunks = Math.ceil(file.size / this.chunkSize);
|
|
597
|
+
const totalUploadSize = estimateTotalUploadSizeBytes(
|
|
598
|
+
file.size,
|
|
599
|
+
totalChunks,
|
|
600
|
+
encrypt
|
|
568
601
|
);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
602
|
+
progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
|
|
603
|
+
const initPayload = {
|
|
604
|
+
filename: transmittedFilename,
|
|
605
|
+
lifetime: lifetimeMs,
|
|
606
|
+
isEncrypted: Boolean(encrypt),
|
|
607
|
+
totalSize: totalUploadSize,
|
|
608
|
+
totalChunks
|
|
609
|
+
};
|
|
610
|
+
const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
|
|
611
|
+
method: "POST",
|
|
612
|
+
timeoutMs: timeouts.initMs ?? 15e3,
|
|
613
|
+
signal: effectiveSignal,
|
|
614
|
+
headers: {
|
|
615
|
+
"Content-Type": "application/json",
|
|
616
|
+
Accept: "application/json"
|
|
617
|
+
},
|
|
618
|
+
body: JSON.stringify(initPayload)
|
|
573
619
|
});
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
620
|
+
if (!initRes.res.ok) {
|
|
621
|
+
const errorJson = initRes.json;
|
|
622
|
+
const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
|
|
623
|
+
throw new DropgateProtocolError(msg, {
|
|
624
|
+
details: initRes.json || initRes.text
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
const initJson = initRes.json;
|
|
628
|
+
const uploadId = initJson?.uploadId;
|
|
629
|
+
if (!uploadId || typeof uploadId !== "string") {
|
|
630
|
+
throw new DropgateProtocolError(
|
|
631
|
+
"Server did not return a valid uploadId."
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
currentUploadId = uploadId;
|
|
635
|
+
currentBaseUrl = baseUrl;
|
|
636
|
+
uploadState = "uploading";
|
|
637
|
+
const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
|
|
638
|
+
const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
|
|
639
|
+
const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
|
|
640
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
641
|
+
if (effectiveSignal?.aborted) {
|
|
642
|
+
throw effectiveSignal.reason || new DropgateAbortError();
|
|
643
|
+
}
|
|
644
|
+
const start = i * this.chunkSize;
|
|
645
|
+
const end = Math.min(start + this.chunkSize, file.size);
|
|
646
|
+
let chunkBlob = file.slice(start, end);
|
|
647
|
+
const percentComplete = i / totalChunks * 100;
|
|
648
|
+
const processedBytes = i * this.chunkSize;
|
|
649
|
+
progress({
|
|
650
|
+
phase: "chunk",
|
|
651
|
+
text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
|
|
652
|
+
percent: percentComplete,
|
|
653
|
+
processedBytes,
|
|
654
|
+
totalBytes: fileSizeBytes,
|
|
655
|
+
chunkIndex: i,
|
|
656
|
+
totalChunks
|
|
657
|
+
});
|
|
658
|
+
const chunkBuffer = await chunkBlob.arrayBuffer();
|
|
659
|
+
let uploadBlob;
|
|
660
|
+
if (encrypt && cryptoKey) {
|
|
661
|
+
uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
|
|
662
|
+
} else {
|
|
663
|
+
uploadBlob = new Blob([chunkBuffer]);
|
|
664
|
+
}
|
|
665
|
+
if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
|
|
666
|
+
throw new DropgateValidationError(
|
|
667
|
+
"Chunk too large (client-side). Check chunk size settings."
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
const toHash = await uploadBlob.arrayBuffer();
|
|
671
|
+
const hashHex = await sha256Hex(this.cryptoObj, toHash);
|
|
672
|
+
const headers = {
|
|
673
|
+
"Content-Type": "application/octet-stream",
|
|
674
|
+
"X-Upload-ID": uploadId,
|
|
675
|
+
"X-Chunk-Index": String(i),
|
|
676
|
+
"X-Chunk-Hash": hashHex
|
|
677
|
+
};
|
|
678
|
+
const chunkUrl = `${baseUrl}/upload/chunk`;
|
|
679
|
+
await this.attemptChunkUpload(
|
|
680
|
+
chunkUrl,
|
|
681
|
+
{
|
|
682
|
+
method: "POST",
|
|
683
|
+
headers,
|
|
684
|
+
body: uploadBlob
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
retries,
|
|
688
|
+
backoffMs: baseBackoffMs,
|
|
689
|
+
maxBackoffMs,
|
|
690
|
+
timeoutMs: timeouts.chunkMs ?? 6e4,
|
|
691
|
+
signal: effectiveSignal,
|
|
692
|
+
progress,
|
|
693
|
+
chunkIndex: i,
|
|
694
|
+
totalChunks,
|
|
695
|
+
chunkSize: this.chunkSize,
|
|
696
|
+
fileSizeBytes
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
|
|
701
|
+
uploadState = "completing";
|
|
702
|
+
const completeRes = await fetchJson(
|
|
703
|
+
this.fetchFn,
|
|
704
|
+
`${baseUrl}/upload/complete`,
|
|
705
|
+
{
|
|
706
|
+
method: "POST",
|
|
707
|
+
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
708
|
+
signal: effectiveSignal,
|
|
709
|
+
headers: {
|
|
710
|
+
"Content-Type": "application/json",
|
|
711
|
+
Accept: "application/json"
|
|
712
|
+
},
|
|
713
|
+
body: JSON.stringify({ uploadId })
|
|
714
|
+
}
|
|
642
715
|
);
|
|
716
|
+
if (!completeRes.res.ok) {
|
|
717
|
+
const errorJson = completeRes.json;
|
|
718
|
+
const msg = errorJson?.error || "Finalisation failed.";
|
|
719
|
+
throw new DropgateProtocolError(msg, {
|
|
720
|
+
details: completeRes.json || completeRes.text
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
const completeJson = completeRes.json;
|
|
724
|
+
const fileId = completeJson?.id;
|
|
725
|
+
if (!fileId || typeof fileId !== "string") {
|
|
726
|
+
throw new DropgateProtocolError(
|
|
727
|
+
"Server did not return a valid file id."
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
let downloadUrl = `${baseUrl}/${fileId}`;
|
|
731
|
+
if (encrypt && keyB64) {
|
|
732
|
+
downloadUrl += `#${keyB64}`;
|
|
733
|
+
}
|
|
734
|
+
progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
|
|
735
|
+
uploadState = "completed";
|
|
736
|
+
return {
|
|
737
|
+
downloadUrl,
|
|
738
|
+
fileId,
|
|
739
|
+
uploadId,
|
|
740
|
+
baseUrl,
|
|
741
|
+
...encrypt && keyB64 ? { keyB64 } : {}
|
|
742
|
+
};
|
|
743
|
+
} catch (err) {
|
|
744
|
+
if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
|
|
745
|
+
uploadState = "cancelled";
|
|
746
|
+
onCancel?.();
|
|
747
|
+
} else {
|
|
748
|
+
uploadState = "error";
|
|
749
|
+
}
|
|
750
|
+
throw err;
|
|
643
751
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
"X-Upload-ID": uploadId,
|
|
649
|
-
"X-Chunk-Index": String(i),
|
|
650
|
-
"X-Chunk-Hash": hashHex
|
|
651
|
-
};
|
|
652
|
-
const chunkUrl = `${baseUrl}/upload/chunk`;
|
|
653
|
-
await this.attemptChunkUpload(
|
|
654
|
-
chunkUrl,
|
|
655
|
-
{
|
|
752
|
+
})();
|
|
753
|
+
const callCancelEndpoint = async (uploadId, baseUrl) => {
|
|
754
|
+
try {
|
|
755
|
+
await fetchJson(this.fetchFn, `${baseUrl}/upload/cancel`, {
|
|
656
756
|
method: "POST",
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
signal,
|
|
666
|
-
progress,
|
|
667
|
-
chunkIndex: i,
|
|
668
|
-
totalChunks
|
|
669
|
-
}
|
|
670
|
-
);
|
|
671
|
-
}
|
|
672
|
-
progress({ phase: "complete", text: "Finalising upload...", percent: 100 });
|
|
673
|
-
const completeRes = await fetchJson(
|
|
674
|
-
this.fetchFn,
|
|
675
|
-
`${baseUrl}/upload/complete`,
|
|
676
|
-
{
|
|
677
|
-
method: "POST",
|
|
678
|
-
timeoutMs: timeouts.completeMs ?? 3e4,
|
|
679
|
-
signal,
|
|
680
|
-
headers: {
|
|
681
|
-
"Content-Type": "application/json",
|
|
682
|
-
Accept: "application/json"
|
|
683
|
-
},
|
|
684
|
-
body: JSON.stringify({ uploadId })
|
|
757
|
+
timeoutMs: 5e3,
|
|
758
|
+
headers: {
|
|
759
|
+
"Content-Type": "application/json",
|
|
760
|
+
Accept: "application/json"
|
|
761
|
+
},
|
|
762
|
+
body: JSON.stringify({ uploadId })
|
|
763
|
+
});
|
|
764
|
+
} catch {
|
|
685
765
|
}
|
|
686
|
-
|
|
687
|
-
if (!completeRes.res.ok) {
|
|
688
|
-
const errorJson = completeRes.json;
|
|
689
|
-
const msg = errorJson?.error || "Finalisation failed.";
|
|
690
|
-
throw new DropgateProtocolError(msg, {
|
|
691
|
-
details: completeRes.json || completeRes.text
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
const completeJson = completeRes.json;
|
|
695
|
-
const fileId = completeJson?.id;
|
|
696
|
-
if (!fileId || typeof fileId !== "string") {
|
|
697
|
-
throw new DropgateProtocolError(
|
|
698
|
-
"Server did not return a valid file id."
|
|
699
|
-
);
|
|
700
|
-
}
|
|
701
|
-
let downloadUrl = `${baseUrl}/${fileId}`;
|
|
702
|
-
if (encrypt && keyB64) {
|
|
703
|
-
downloadUrl += `#${keyB64}`;
|
|
704
|
-
}
|
|
705
|
-
progress({ phase: "done", text: "Upload successful!", percent: 100 });
|
|
766
|
+
};
|
|
706
767
|
return {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
768
|
+
result: uploadPromise,
|
|
769
|
+
cancel: (reason) => {
|
|
770
|
+
if (uploadState === "completed" || uploadState === "cancelled") return;
|
|
771
|
+
uploadState = "cancelled";
|
|
772
|
+
if (currentUploadId && currentBaseUrl) {
|
|
773
|
+
callCancelEndpoint(currentUploadId, currentBaseUrl).catch(() => {
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
|
|
777
|
+
},
|
|
778
|
+
getStatus: () => uploadState
|
|
712
779
|
};
|
|
713
780
|
}
|
|
714
781
|
/**
|
|
@@ -748,8 +815,20 @@ var DropgateClient = class {
|
|
|
748
815
|
if (!fileId || typeof fileId !== "string") {
|
|
749
816
|
throw new DropgateValidationError("File ID is required.");
|
|
750
817
|
}
|
|
751
|
-
|
|
752
|
-
|
|
818
|
+
progress({ phase: "server-info", text: "Checking server...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
819
|
+
const compat = await this.checkCompatibility({
|
|
820
|
+
host,
|
|
821
|
+
port,
|
|
822
|
+
secure,
|
|
823
|
+
timeoutMs,
|
|
824
|
+
signal
|
|
825
|
+
});
|
|
826
|
+
const { baseUrl } = compat;
|
|
827
|
+
progress({ phase: "server-compat", text: compat.message, processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
828
|
+
if (!compat.compatible) {
|
|
829
|
+
throw new DropgateValidationError(compat.message);
|
|
830
|
+
}
|
|
831
|
+
progress({ phase: "metadata", text: "Fetching file info...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
753
832
|
const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
|
|
754
833
|
let metadata;
|
|
755
834
|
try {
|
|
@@ -792,7 +871,7 @@ var DropgateClient = class {
|
|
|
792
871
|
if (!this.cryptoObj?.subtle) {
|
|
793
872
|
throw new DropgateValidationError("Web Crypto API not available for decryption.");
|
|
794
873
|
}
|
|
795
|
-
progress({ phase: "decrypting", text: "Preparing decryption...",
|
|
874
|
+
progress({ phase: "decrypting", text: "Preparing decryption...", processedBytes: 0, totalBytes: 0, percent: 0 });
|
|
796
875
|
try {
|
|
797
876
|
cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
|
|
798
877
|
filename = await decryptFilenameFromBase64(
|
|
@@ -810,7 +889,7 @@ var DropgateClient = class {
|
|
|
810
889
|
} else {
|
|
811
890
|
filename = metadata.filename || "file";
|
|
812
891
|
}
|
|
813
|
-
progress({ phase: "downloading", text: "Starting download...", percent: 0,
|
|
892
|
+
progress({ phase: "downloading", text: "Starting download...", percent: 0, processedBytes: 0, totalBytes });
|
|
814
893
|
const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
|
|
815
894
|
let receivedBytes = 0;
|
|
816
895
|
const dataChunks = [];
|
|
@@ -879,7 +958,7 @@ var DropgateClient = class {
|
|
|
879
958
|
phase: "decrypting",
|
|
880
959
|
text: `Downloading & decrypting... (${percent}%)`,
|
|
881
960
|
percent,
|
|
882
|
-
receivedBytes,
|
|
961
|
+
processedBytes: receivedBytes,
|
|
883
962
|
totalBytes
|
|
884
963
|
});
|
|
885
964
|
}
|
|
@@ -911,7 +990,7 @@ var DropgateClient = class {
|
|
|
911
990
|
phase: "downloading",
|
|
912
991
|
text: `Downloading... (${percent}%)`,
|
|
913
992
|
percent,
|
|
914
|
-
receivedBytes,
|
|
993
|
+
processedBytes: receivedBytes,
|
|
915
994
|
totalBytes
|
|
916
995
|
});
|
|
917
996
|
}
|
|
@@ -925,7 +1004,7 @@ var DropgateClient = class {
|
|
|
925
1004
|
} finally {
|
|
926
1005
|
downloadCleanup();
|
|
927
1006
|
}
|
|
928
|
-
progress({ phase: "complete", text: "Download complete!", percent: 100, receivedBytes, totalBytes });
|
|
1007
|
+
progress({ phase: "complete", text: "Download complete!", percent: 100, processedBytes: receivedBytes, totalBytes });
|
|
929
1008
|
let data;
|
|
930
1009
|
if (collectData && dataChunks.length > 0) {
|
|
931
1010
|
const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
@@ -952,7 +1031,9 @@ var DropgateClient = class {
|
|
|
952
1031
|
signal,
|
|
953
1032
|
progress,
|
|
954
1033
|
chunkIndex,
|
|
955
|
-
totalChunks
|
|
1034
|
+
totalChunks,
|
|
1035
|
+
chunkSize,
|
|
1036
|
+
fileSizeBytes
|
|
956
1037
|
} = opts;
|
|
957
1038
|
let attemptsLeft = retries;
|
|
958
1039
|
let currentBackoff = backoffMs;
|
|
@@ -985,6 +1066,8 @@ var DropgateClient = class {
|
|
|
985
1066
|
throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
|
|
986
1067
|
}
|
|
987
1068
|
const attemptNumber = maxRetries - attemptsLeft + 1;
|
|
1069
|
+
const processedBytes = chunkIndex * chunkSize;
|
|
1070
|
+
const percent = chunkIndex / totalChunks * 100;
|
|
988
1071
|
let remaining = currentBackoff;
|
|
989
1072
|
const tick = 100;
|
|
990
1073
|
while (remaining > 0) {
|
|
@@ -992,6 +1075,9 @@ var DropgateClient = class {
|
|
|
992
1075
|
progress({
|
|
993
1076
|
phase: "retry-wait",
|
|
994
1077
|
text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
|
|
1078
|
+
percent,
|
|
1079
|
+
processedBytes,
|
|
1080
|
+
totalBytes: fileSizeBytes,
|
|
995
1081
|
chunkIndex,
|
|
996
1082
|
totalChunks
|
|
997
1083
|
});
|
|
@@ -1001,6 +1087,9 @@ var DropgateClient = class {
|
|
|
1001
1087
|
progress({
|
|
1002
1088
|
phase: "retry",
|
|
1003
1089
|
text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
|
|
1090
|
+
percent,
|
|
1091
|
+
processedBytes,
|
|
1092
|
+
totalBytes: fileSizeBytes,
|
|
1004
1093
|
chunkIndex,
|
|
1005
1094
|
totalChunks
|
|
1006
1095
|
});
|
|
@@ -1023,11 +1112,11 @@ function isSecureContextForP2P(hostname, isSecureContext) {
|
|
|
1023
1112
|
return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
|
|
1024
1113
|
}
|
|
1025
1114
|
function generateP2PCode(cryptoObj) {
|
|
1026
|
-
const
|
|
1115
|
+
const crypto2 = cryptoObj || getDefaultCrypto();
|
|
1027
1116
|
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
1028
|
-
if (
|
|
1117
|
+
if (crypto2) {
|
|
1029
1118
|
const randomBytes = new Uint8Array(8);
|
|
1030
|
-
|
|
1119
|
+
crypto2.getRandomValues(randomBytes);
|
|
1031
1120
|
let letterPart = "";
|
|
1032
1121
|
for (let i = 0; i < 4; i++) {
|
|
1033
1122
|
letterPart += letters[randomBytes[i] % letters.length];
|
|
@@ -1053,8 +1142,14 @@ function isP2PCodeLike(code) {
|
|
|
1053
1142
|
}
|
|
1054
1143
|
|
|
1055
1144
|
// src/p2p/helpers.ts
|
|
1056
|
-
function
|
|
1057
|
-
|
|
1145
|
+
function resolvePeerConfig(userConfig, serverCaps) {
|
|
1146
|
+
return {
|
|
1147
|
+
path: userConfig.peerjsPath ?? serverCaps?.peerjsPath ?? "/peerjs",
|
|
1148
|
+
iceServers: userConfig.iceServers ?? serverCaps?.iceServers ?? []
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
function buildPeerOptions(config = {}) {
|
|
1152
|
+
const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = config;
|
|
1058
1153
|
const peerOpts = {
|
|
1059
1154
|
host,
|
|
1060
1155
|
path: peerjsPath,
|
|
@@ -1096,6 +1191,12 @@ async function createPeerWithRetries(opts) {
|
|
|
1096
1191
|
}
|
|
1097
1192
|
|
|
1098
1193
|
// src/p2p/send.ts
|
|
1194
|
+
function generateSessionId() {
|
|
1195
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
1196
|
+
return crypto.randomUUID();
|
|
1197
|
+
}
|
|
1198
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
1199
|
+
}
|
|
1099
1200
|
async function startP2PSend(opts) {
|
|
1100
1201
|
const {
|
|
1101
1202
|
file,
|
|
@@ -1110,15 +1211,17 @@ async function startP2PSend(opts) {
|
|
|
1110
1211
|
cryptoObj,
|
|
1111
1212
|
maxAttempts = 4,
|
|
1112
1213
|
chunkSize = 256 * 1024,
|
|
1113
|
-
readyTimeoutMs = 8e3,
|
|
1114
1214
|
endAckTimeoutMs = 15e3,
|
|
1115
1215
|
bufferHighWaterMark = 8 * 1024 * 1024,
|
|
1116
1216
|
bufferLowWaterMark = 2 * 1024 * 1024,
|
|
1217
|
+
heartbeatIntervalMs = 5e3,
|
|
1117
1218
|
onCode,
|
|
1118
1219
|
onStatus,
|
|
1119
1220
|
onProgress,
|
|
1120
1221
|
onComplete,
|
|
1121
|
-
onError
|
|
1222
|
+
onError,
|
|
1223
|
+
onDisconnect,
|
|
1224
|
+
onCancel
|
|
1122
1225
|
} = opts;
|
|
1123
1226
|
if (!file) {
|
|
1124
1227
|
throw new DropgateValidationError("File is missing.");
|
|
@@ -1132,8 +1235,10 @@ async function startP2PSend(opts) {
|
|
|
1132
1235
|
if (serverInfo && !p2pCaps?.enabled) {
|
|
1133
1236
|
throw new DropgateValidationError("Direct transfer is disabled on this server.");
|
|
1134
1237
|
}
|
|
1135
|
-
const finalPath
|
|
1136
|
-
|
|
1238
|
+
const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
|
|
1239
|
+
{ peerjsPath, iceServers },
|
|
1240
|
+
p2pCaps
|
|
1241
|
+
);
|
|
1137
1242
|
const peerOpts = buildPeerOptions({
|
|
1138
1243
|
host,
|
|
1139
1244
|
port,
|
|
@@ -1150,18 +1255,37 @@ async function startP2PSend(opts) {
|
|
|
1150
1255
|
buildPeer,
|
|
1151
1256
|
onCode
|
|
1152
1257
|
});
|
|
1153
|
-
|
|
1258
|
+
const sessionId = generateSessionId();
|
|
1259
|
+
let state = "listening";
|
|
1154
1260
|
let activeConn = null;
|
|
1155
|
-
let
|
|
1156
|
-
let
|
|
1261
|
+
let sentBytes = 0;
|
|
1262
|
+
let heartbeatTimer = null;
|
|
1157
1263
|
const reportProgress = (data) => {
|
|
1158
1264
|
const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
|
|
1159
1265
|
const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
|
|
1160
1266
|
const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
|
|
1161
|
-
onProgress?.({
|
|
1267
|
+
onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
|
|
1162
1268
|
};
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1269
|
+
const safeError = (err) => {
|
|
1270
|
+
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1271
|
+
state = "closed";
|
|
1272
|
+
onError?.(err);
|
|
1273
|
+
cleanup();
|
|
1274
|
+
};
|
|
1275
|
+
const safeComplete = () => {
|
|
1276
|
+
if (state !== "finishing") return;
|
|
1277
|
+
state = "completed";
|
|
1278
|
+
onComplete?.();
|
|
1279
|
+
cleanup();
|
|
1280
|
+
};
|
|
1281
|
+
const cleanup = () => {
|
|
1282
|
+
if (heartbeatTimer) {
|
|
1283
|
+
clearInterval(heartbeatTimer);
|
|
1284
|
+
heartbeatTimer = null;
|
|
1285
|
+
}
|
|
1286
|
+
if (typeof window !== "undefined") {
|
|
1287
|
+
window.removeEventListener("beforeunload", handleUnload);
|
|
1288
|
+
}
|
|
1165
1289
|
try {
|
|
1166
1290
|
activeConn?.close();
|
|
1167
1291
|
} catch {
|
|
@@ -1171,21 +1295,69 @@ async function startP2PSend(opts) {
|
|
|
1171
1295
|
} catch {
|
|
1172
1296
|
}
|
|
1173
1297
|
};
|
|
1298
|
+
const handleUnload = () => {
|
|
1299
|
+
try {
|
|
1300
|
+
activeConn?.send({ t: "error", message: "Sender closed the connection." });
|
|
1301
|
+
} catch {
|
|
1302
|
+
}
|
|
1303
|
+
stop();
|
|
1304
|
+
};
|
|
1305
|
+
if (typeof window !== "undefined") {
|
|
1306
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
1307
|
+
}
|
|
1308
|
+
const stop = () => {
|
|
1309
|
+
if (state === "closed" || state === "cancelled") return;
|
|
1310
|
+
const wasActive = state === "transferring" || state === "finishing";
|
|
1311
|
+
state = "cancelled";
|
|
1312
|
+
try {
|
|
1313
|
+
if (activeConn && activeConn.open) {
|
|
1314
|
+
activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
|
|
1315
|
+
}
|
|
1316
|
+
} catch {
|
|
1317
|
+
}
|
|
1318
|
+
if (wasActive && onCancel) {
|
|
1319
|
+
onCancel({ cancelledBy: "sender" });
|
|
1320
|
+
}
|
|
1321
|
+
cleanup();
|
|
1322
|
+
};
|
|
1323
|
+
const isStopped = () => state === "closed" || state === "cancelled";
|
|
1174
1324
|
peer.on("connection", (conn) => {
|
|
1175
|
-
if (
|
|
1325
|
+
if (state === "closed") return;
|
|
1176
1326
|
if (activeConn) {
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1327
|
+
const isOldConnOpen = activeConn.open !== false;
|
|
1328
|
+
if (isOldConnOpen && state === "transferring") {
|
|
1329
|
+
try {
|
|
1330
|
+
conn.send({ t: "error", message: "Transfer already in progress." });
|
|
1331
|
+
} catch {
|
|
1332
|
+
}
|
|
1333
|
+
try {
|
|
1334
|
+
conn.close();
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1337
|
+
return;
|
|
1338
|
+
} else if (!isOldConnOpen) {
|
|
1339
|
+
try {
|
|
1340
|
+
activeConn.close();
|
|
1341
|
+
} catch {
|
|
1342
|
+
}
|
|
1343
|
+
activeConn = null;
|
|
1344
|
+
state = "listening";
|
|
1345
|
+
sentBytes = 0;
|
|
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 {
|
|
1354
|
+
}
|
|
1355
|
+
return;
|
|
1184
1356
|
}
|
|
1185
|
-
return;
|
|
1186
1357
|
}
|
|
1187
1358
|
activeConn = conn;
|
|
1188
|
-
|
|
1359
|
+
state = "negotiating";
|
|
1360
|
+
onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
|
|
1189
1361
|
let readyResolve = null;
|
|
1190
1362
|
let ackResolve = null;
|
|
1191
1363
|
const readyPromise = new Promise((resolve) => {
|
|
@@ -1201,6 +1373,7 @@ async function startP2PSend(opts) {
|
|
|
1201
1373
|
const msg = data;
|
|
1202
1374
|
if (!msg.t) return;
|
|
1203
1375
|
if (msg.t === "ready") {
|
|
1376
|
+
onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
|
|
1204
1377
|
readyResolve?.();
|
|
1205
1378
|
return;
|
|
1206
1379
|
}
|
|
@@ -1212,22 +1385,30 @@ async function startP2PSend(opts) {
|
|
|
1212
1385
|
ackResolve?.(msg);
|
|
1213
1386
|
return;
|
|
1214
1387
|
}
|
|
1388
|
+
if (msg.t === "pong") {
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1215
1391
|
if (msg.t === "error") {
|
|
1216
|
-
|
|
1217
|
-
|
|
1392
|
+
safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (msg.t === "cancelled") {
|
|
1396
|
+
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
1397
|
+
state = "cancelled";
|
|
1398
|
+
onCancel?.({ cancelledBy: "receiver", message: msg.message });
|
|
1399
|
+
cleanup();
|
|
1218
1400
|
}
|
|
1219
1401
|
});
|
|
1220
1402
|
conn.on("open", async () => {
|
|
1221
1403
|
try {
|
|
1222
|
-
|
|
1223
|
-
if (stopped) return;
|
|
1404
|
+
if (isStopped()) return;
|
|
1224
1405
|
conn.send({
|
|
1225
1406
|
t: "meta",
|
|
1407
|
+
sessionId,
|
|
1226
1408
|
name: file.name,
|
|
1227
1409
|
size: file.size,
|
|
1228
1410
|
mime: file.type || "application/octet-stream"
|
|
1229
1411
|
});
|
|
1230
|
-
let sent = 0;
|
|
1231
1412
|
const total = file.size;
|
|
1232
1413
|
const dc = conn._dc;
|
|
1233
1414
|
if (dc && Number.isFinite(bufferLowWaterMark)) {
|
|
@@ -1236,13 +1417,26 @@ async function startP2PSend(opts) {
|
|
|
1236
1417
|
} catch {
|
|
1237
1418
|
}
|
|
1238
1419
|
}
|
|
1239
|
-
await
|
|
1420
|
+
await readyPromise;
|
|
1421
|
+
if (isStopped()) return;
|
|
1422
|
+
if (heartbeatIntervalMs > 0) {
|
|
1423
|
+
heartbeatTimer = setInterval(() => {
|
|
1424
|
+
if (state === "transferring" || state === "finishing") {
|
|
1425
|
+
try {
|
|
1426
|
+
conn.send({ t: "ping" });
|
|
1427
|
+
} catch {
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}, heartbeatIntervalMs);
|
|
1431
|
+
}
|
|
1432
|
+
state = "transferring";
|
|
1240
1433
|
for (let offset = 0; offset < total; offset += chunkSize) {
|
|
1241
|
-
if (
|
|
1434
|
+
if (isStopped()) return;
|
|
1242
1435
|
const slice = file.slice(offset, offset + chunkSize);
|
|
1243
1436
|
const buf = await slice.arrayBuffer();
|
|
1437
|
+
if (isStopped()) return;
|
|
1244
1438
|
conn.send(buf);
|
|
1245
|
-
|
|
1439
|
+
sentBytes += buf.byteLength;
|
|
1246
1440
|
if (dc) {
|
|
1247
1441
|
while (dc.bufferedAmount > bufferHighWaterMark) {
|
|
1248
1442
|
await new Promise((resolve) => {
|
|
@@ -1262,13 +1456,15 @@ async function startP2PSend(opts) {
|
|
|
1262
1456
|
}
|
|
1263
1457
|
}
|
|
1264
1458
|
}
|
|
1265
|
-
if (
|
|
1459
|
+
if (isStopped()) return;
|
|
1460
|
+
state = "finishing";
|
|
1266
1461
|
conn.send({ t: "end" });
|
|
1267
1462
|
const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
|
|
1268
1463
|
const ackResult = await Promise.race([
|
|
1269
1464
|
ackPromise,
|
|
1270
1465
|
sleep(ackTimeoutMs || 15e3).catch(() => null)
|
|
1271
1466
|
]);
|
|
1467
|
+
if (isStopped()) return;
|
|
1272
1468
|
if (!ackResult || typeof ackResult !== "object") {
|
|
1273
1469
|
throw new DropgateNetworkError("Receiver did not confirm completion.");
|
|
1274
1470
|
}
|
|
@@ -1279,29 +1475,43 @@ async function startP2PSend(opts) {
|
|
|
1279
1475
|
throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
|
|
1280
1476
|
}
|
|
1281
1477
|
reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
|
|
1282
|
-
|
|
1283
|
-
transferActive = false;
|
|
1284
|
-
onComplete?.();
|
|
1285
|
-
stop();
|
|
1478
|
+
safeComplete();
|
|
1286
1479
|
} catch (err) {
|
|
1287
|
-
|
|
1288
|
-
stop();
|
|
1480
|
+
safeError(err);
|
|
1289
1481
|
}
|
|
1290
1482
|
});
|
|
1291
1483
|
conn.on("error", (err) => {
|
|
1292
|
-
|
|
1293
|
-
stop();
|
|
1484
|
+
safeError(err);
|
|
1294
1485
|
});
|
|
1295
1486
|
conn.on("close", () => {
|
|
1296
|
-
if (
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1487
|
+
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
1488
|
+
cleanup();
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (state === "transferring" || state === "finishing") {
|
|
1492
|
+
state = "cancelled";
|
|
1493
|
+
onCancel?.({ cancelledBy: "receiver" });
|
|
1494
|
+
cleanup();
|
|
1495
|
+
} else {
|
|
1496
|
+
activeConn = null;
|
|
1497
|
+
state = "listening";
|
|
1498
|
+
sentBytes = 0;
|
|
1499
|
+
onDisconnect?.();
|
|
1300
1500
|
}
|
|
1301
|
-
stop();
|
|
1302
1501
|
});
|
|
1303
1502
|
});
|
|
1304
|
-
return {
|
|
1503
|
+
return {
|
|
1504
|
+
peer,
|
|
1505
|
+
code,
|
|
1506
|
+
sessionId,
|
|
1507
|
+
stop,
|
|
1508
|
+
getStatus: () => state,
|
|
1509
|
+
getBytesSent: () => sentBytes,
|
|
1510
|
+
getConnectedPeerId: () => {
|
|
1511
|
+
if (!activeConn) return null;
|
|
1512
|
+
return activeConn.peer || null;
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1305
1515
|
}
|
|
1306
1516
|
|
|
1307
1517
|
// src/p2p/receive.ts
|
|
@@ -1315,13 +1525,16 @@ async function startP2PReceive(opts) {
|
|
|
1315
1525
|
peerjsPath,
|
|
1316
1526
|
secure = false,
|
|
1317
1527
|
iceServers,
|
|
1528
|
+
autoReady = true,
|
|
1529
|
+
watchdogTimeoutMs = 15e3,
|
|
1318
1530
|
onStatus,
|
|
1319
1531
|
onMeta,
|
|
1320
1532
|
onData,
|
|
1321
1533
|
onProgress,
|
|
1322
1534
|
onComplete,
|
|
1323
1535
|
onError,
|
|
1324
|
-
onDisconnect
|
|
1536
|
+
onDisconnect,
|
|
1537
|
+
onCancel
|
|
1325
1538
|
} = opts;
|
|
1326
1539
|
if (!code) {
|
|
1327
1540
|
throw new DropgateValidationError("No sharing code was provided.");
|
|
@@ -1339,8 +1552,10 @@ async function startP2PReceive(opts) {
|
|
|
1339
1552
|
if (!isP2PCodeLike(normalizedCode)) {
|
|
1340
1553
|
throw new DropgateValidationError("Invalid direct transfer code.");
|
|
1341
1554
|
}
|
|
1342
|
-
const finalPath
|
|
1343
|
-
|
|
1555
|
+
const { path: finalPath, iceServers: finalIceServers } = resolvePeerConfig(
|
|
1556
|
+
{ peerjsPath, iceServers },
|
|
1557
|
+
p2pCaps
|
|
1558
|
+
);
|
|
1344
1559
|
const peerOpts = buildPeerOptions({
|
|
1345
1560
|
host,
|
|
1346
1561
|
port,
|
|
@@ -1349,44 +1564,137 @@ async function startP2PReceive(opts) {
|
|
|
1349
1564
|
iceServers: finalIceServers
|
|
1350
1565
|
});
|
|
1351
1566
|
const peer = new Peer(void 0, peerOpts);
|
|
1567
|
+
let state = "initializing";
|
|
1352
1568
|
let total = 0;
|
|
1353
1569
|
let received = 0;
|
|
1570
|
+
let currentSessionId = null;
|
|
1354
1571
|
let lastProgressSentAt = 0;
|
|
1355
1572
|
const progressIntervalMs = 120;
|
|
1356
1573
|
let writeQueue = Promise.resolve();
|
|
1357
|
-
|
|
1574
|
+
let watchdogTimer = null;
|
|
1575
|
+
let activeConn = null;
|
|
1576
|
+
const resetWatchdog = () => {
|
|
1577
|
+
if (watchdogTimeoutMs <= 0) return;
|
|
1578
|
+
if (watchdogTimer) {
|
|
1579
|
+
clearTimeout(watchdogTimer);
|
|
1580
|
+
}
|
|
1581
|
+
watchdogTimer = setTimeout(() => {
|
|
1582
|
+
if (state === "transferring") {
|
|
1583
|
+
safeError(new DropgateNetworkError("Connection timed out (no data received)."));
|
|
1584
|
+
}
|
|
1585
|
+
}, watchdogTimeoutMs);
|
|
1586
|
+
};
|
|
1587
|
+
const clearWatchdog = () => {
|
|
1588
|
+
if (watchdogTimer) {
|
|
1589
|
+
clearTimeout(watchdogTimer);
|
|
1590
|
+
watchdogTimer = null;
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
const safeError = (err) => {
|
|
1594
|
+
if (state === "closed" || state === "completed" || state === "cancelled") return;
|
|
1595
|
+
state = "closed";
|
|
1596
|
+
onError?.(err);
|
|
1597
|
+
cleanup();
|
|
1598
|
+
};
|
|
1599
|
+
const safeComplete = (completeData) => {
|
|
1600
|
+
if (state !== "transferring") return;
|
|
1601
|
+
state = "completed";
|
|
1602
|
+
onComplete?.(completeData);
|
|
1603
|
+
cleanup();
|
|
1604
|
+
};
|
|
1605
|
+
const cleanup = () => {
|
|
1606
|
+
clearWatchdog();
|
|
1607
|
+
if (typeof window !== "undefined") {
|
|
1608
|
+
window.removeEventListener("beforeunload", handleUnload);
|
|
1609
|
+
}
|
|
1358
1610
|
try {
|
|
1359
1611
|
peer.destroy();
|
|
1360
1612
|
} catch {
|
|
1361
1613
|
}
|
|
1362
1614
|
};
|
|
1363
|
-
|
|
1364
|
-
|
|
1615
|
+
const handleUnload = () => {
|
|
1616
|
+
try {
|
|
1617
|
+
activeConn?.send({ t: "error", message: "Receiver closed the connection." });
|
|
1618
|
+
} catch {
|
|
1619
|
+
}
|
|
1365
1620
|
stop();
|
|
1621
|
+
};
|
|
1622
|
+
if (typeof window !== "undefined") {
|
|
1623
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
1624
|
+
}
|
|
1625
|
+
const stop = () => {
|
|
1626
|
+
if (state === "closed" || state === "cancelled") return;
|
|
1627
|
+
const wasActive = state === "transferring";
|
|
1628
|
+
state = "cancelled";
|
|
1629
|
+
try {
|
|
1630
|
+
if (activeConn && activeConn.open) {
|
|
1631
|
+
activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
|
|
1632
|
+
}
|
|
1633
|
+
} catch {
|
|
1634
|
+
}
|
|
1635
|
+
if (wasActive && onCancel) {
|
|
1636
|
+
onCancel({ cancelledBy: "receiver" });
|
|
1637
|
+
}
|
|
1638
|
+
cleanup();
|
|
1639
|
+
};
|
|
1640
|
+
peer.on("error", (err) => {
|
|
1641
|
+
safeError(err);
|
|
1366
1642
|
});
|
|
1367
1643
|
peer.on("open", () => {
|
|
1644
|
+
state = "connecting";
|
|
1368
1645
|
const conn = peer.connect(normalizedCode, { reliable: true });
|
|
1646
|
+
activeConn = conn;
|
|
1369
1647
|
conn.on("open", () => {
|
|
1648
|
+
state = "negotiating";
|
|
1370
1649
|
onStatus?.({ phase: "connected", message: "Waiting for file details..." });
|
|
1371
1650
|
});
|
|
1372
1651
|
conn.on("data", async (data) => {
|
|
1373
1652
|
try {
|
|
1653
|
+
resetWatchdog();
|
|
1374
1654
|
if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
|
|
1375
1655
|
const msg = data;
|
|
1376
1656
|
if (msg.t === "meta") {
|
|
1657
|
+
if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
|
|
1658
|
+
try {
|
|
1659
|
+
conn.send({ t: "error", message: "Busy with another session." });
|
|
1660
|
+
} catch {
|
|
1661
|
+
}
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (msg.sessionId) {
|
|
1665
|
+
currentSessionId = msg.sessionId;
|
|
1666
|
+
}
|
|
1377
1667
|
const name = String(msg.name || "file");
|
|
1378
1668
|
total = Number(msg.size) || 0;
|
|
1379
1669
|
received = 0;
|
|
1380
1670
|
writeQueue = Promise.resolve();
|
|
1381
|
-
|
|
1382
|
-
|
|
1671
|
+
const sendReady = () => {
|
|
1672
|
+
state = "transferring";
|
|
1673
|
+
resetWatchdog();
|
|
1674
|
+
try {
|
|
1675
|
+
conn.send({ t: "ready" });
|
|
1676
|
+
} catch {
|
|
1677
|
+
}
|
|
1678
|
+
};
|
|
1679
|
+
if (autoReady) {
|
|
1680
|
+
onMeta?.({ name, total });
|
|
1681
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1682
|
+
sendReady();
|
|
1683
|
+
} else {
|
|
1684
|
+
onMeta?.({ name, total, sendReady });
|
|
1685
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
|
|
1686
|
+
}
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if (msg.t === "ping") {
|
|
1383
1690
|
try {
|
|
1384
|
-
conn.send({ t: "
|
|
1691
|
+
conn.send({ t: "pong" });
|
|
1385
1692
|
} catch {
|
|
1386
1693
|
}
|
|
1387
1694
|
return;
|
|
1388
1695
|
}
|
|
1389
1696
|
if (msg.t === "end") {
|
|
1697
|
+
clearWatchdog();
|
|
1390
1698
|
await writeQueue;
|
|
1391
1699
|
if (total && received < total) {
|
|
1392
1700
|
const err = new DropgateNetworkError(
|
|
@@ -1398,16 +1706,23 @@ async function startP2PReceive(opts) {
|
|
|
1398
1706
|
}
|
|
1399
1707
|
throw err;
|
|
1400
1708
|
}
|
|
1401
|
-
onComplete?.({ received, total });
|
|
1402
1709
|
try {
|
|
1403
1710
|
conn.send({ t: "ack", phase: "end", received, total });
|
|
1404
1711
|
} catch {
|
|
1405
1712
|
}
|
|
1713
|
+
safeComplete({ received, total });
|
|
1406
1714
|
return;
|
|
1407
1715
|
}
|
|
1408
1716
|
if (msg.t === "error") {
|
|
1409
1717
|
throw new DropgateNetworkError(msg.message || "Sender reported an error.");
|
|
1410
1718
|
}
|
|
1719
|
+
if (msg.t === "cancelled") {
|
|
1720
|
+
if (state === "cancelled" || state === "closed" || state === "completed") return;
|
|
1721
|
+
state = "cancelled";
|
|
1722
|
+
onCancel?.({ cancelledBy: "sender", message: msg.message });
|
|
1723
|
+
cleanup();
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1411
1726
|
return;
|
|
1412
1727
|
}
|
|
1413
1728
|
let bufPromise;
|
|
@@ -1429,7 +1744,7 @@ async function startP2PReceive(opts) {
|
|
|
1429
1744
|
}
|
|
1430
1745
|
received += buf.byteLength;
|
|
1431
1746
|
const percent = total ? Math.min(100, received / total * 100) : 0;
|
|
1432
|
-
onProgress?.({ received, total, percent });
|
|
1747
|
+
onProgress?.({ processedBytes: received, totalBytes: total, percent });
|
|
1433
1748
|
const now = Date.now();
|
|
1434
1749
|
if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
|
|
1435
1750
|
lastProgressSentAt = now;
|
|
@@ -1446,21 +1761,38 @@ async function startP2PReceive(opts) {
|
|
|
1446
1761
|
});
|
|
1447
1762
|
} catch {
|
|
1448
1763
|
}
|
|
1449
|
-
|
|
1450
|
-
stop();
|
|
1764
|
+
safeError(err);
|
|
1451
1765
|
});
|
|
1452
1766
|
} catch (err) {
|
|
1453
|
-
|
|
1454
|
-
stop();
|
|
1767
|
+
safeError(err);
|
|
1455
1768
|
}
|
|
1456
1769
|
});
|
|
1457
1770
|
conn.on("close", () => {
|
|
1458
|
-
if (
|
|
1771
|
+
if (state === "closed" || state === "completed" || state === "cancelled") {
|
|
1772
|
+
cleanup();
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (state === "transferring") {
|
|
1776
|
+
state = "cancelled";
|
|
1777
|
+
onCancel?.({ cancelledBy: "sender" });
|
|
1778
|
+
cleanup();
|
|
1779
|
+
} else if (state === "negotiating") {
|
|
1780
|
+
state = "closed";
|
|
1781
|
+
cleanup();
|
|
1459
1782
|
onDisconnect?.();
|
|
1783
|
+
} else {
|
|
1784
|
+
safeError(new DropgateNetworkError("Sender disconnected before file details were received."));
|
|
1460
1785
|
}
|
|
1461
1786
|
});
|
|
1462
1787
|
});
|
|
1463
|
-
return {
|
|
1788
|
+
return {
|
|
1789
|
+
peer,
|
|
1790
|
+
stop,
|
|
1791
|
+
getStatus: () => state,
|
|
1792
|
+
getBytesReceived: () => received,
|
|
1793
|
+
getTotalBytes: () => total,
|
|
1794
|
+
getSessionId: () => currentSessionId
|
|
1795
|
+
};
|
|
1464
1796
|
}
|
|
1465
1797
|
export {
|
|
1466
1798
|
AES_GCM_IV_BYTES,
|
|
@@ -1492,6 +1824,7 @@ export {
|
|
|
1492
1824
|
getDefaultBase64,
|
|
1493
1825
|
getDefaultCrypto,
|
|
1494
1826
|
getDefaultFetch,
|
|
1827
|
+
getServerInfo,
|
|
1495
1828
|
importKeyFromBase64,
|
|
1496
1829
|
isLocalhostHostname,
|
|
1497
1830
|
isP2PCodeLike,
|
|
@@ -1500,6 +1833,7 @@ export {
|
|
|
1500
1833
|
makeAbortSignal,
|
|
1501
1834
|
parseSemverMajorMinor,
|
|
1502
1835
|
parseServerUrl,
|
|
1836
|
+
resolvePeerConfig,
|
|
1503
1837
|
sha256Hex,
|
|
1504
1838
|
sleep,
|
|
1505
1839
|
startP2PReceive,
|