@dropgate/core 2.0.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/dist/index.js ADDED
@@ -0,0 +1,1509 @@
1
+ // src/constants.ts
2
+ var DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
3
+ var AES_GCM_IV_BYTES = 12;
4
+ var AES_GCM_TAG_BYTES = 16;
5
+ var ENCRYPTION_OVERHEAD_PER_CHUNK = AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES;
6
+ var MAX_IN_MEMORY_DOWNLOAD_BYTES = 100 * 1024 * 1024;
7
+
8
+ // src/errors.ts
9
+ var DropgateError = class extends Error {
10
+ constructor(message, opts = {}) {
11
+ super(message);
12
+ this.name = this.constructor.name;
13
+ this.code = opts.code || "DROPGATE_ERROR";
14
+ this.details = opts.details;
15
+ if (opts.cause !== void 0) {
16
+ Object.defineProperty(this, "cause", {
17
+ value: opts.cause,
18
+ writable: false,
19
+ enumerable: false,
20
+ configurable: true
21
+ });
22
+ }
23
+ }
24
+ };
25
+ var DropgateValidationError = class extends DropgateError {
26
+ constructor(message, opts = {}) {
27
+ super(message, { ...opts, code: opts.code || "VALIDATION_ERROR" });
28
+ }
29
+ };
30
+ var DropgateNetworkError = class extends DropgateError {
31
+ constructor(message, opts = {}) {
32
+ super(message, { ...opts, code: opts.code || "NETWORK_ERROR" });
33
+ }
34
+ };
35
+ var DropgateProtocolError = class extends DropgateError {
36
+ constructor(message, opts = {}) {
37
+ super(message, { ...opts, code: opts.code || "PROTOCOL_ERROR" });
38
+ }
39
+ };
40
+ var DropgateAbortError = class extends DropgateError {
41
+ constructor(message = "Operation aborted") {
42
+ super(message, { code: "ABORT_ERROR" });
43
+ this.name = "AbortError";
44
+ }
45
+ };
46
+ var DropgateTimeoutError = class extends DropgateError {
47
+ constructor(message = "Request timed out") {
48
+ super(message, { code: "TIMEOUT_ERROR" });
49
+ this.name = "TimeoutError";
50
+ }
51
+ };
52
+
53
+ // src/adapters/defaults.ts
54
+ function getDefaultBase64() {
55
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
56
+ return {
57
+ encode(bytes) {
58
+ return Buffer.from(bytes).toString("base64");
59
+ },
60
+ decode(b64) {
61
+ return new Uint8Array(Buffer.from(b64, "base64"));
62
+ }
63
+ };
64
+ }
65
+ if (typeof btoa === "function" && typeof atob === "function") {
66
+ return {
67
+ encode(bytes) {
68
+ let binary = "";
69
+ for (let i = 0; i < bytes.length; i++) {
70
+ binary += String.fromCharCode(bytes[i]);
71
+ }
72
+ return btoa(binary);
73
+ },
74
+ decode(b64) {
75
+ const binary = atob(b64);
76
+ const out = new Uint8Array(binary.length);
77
+ for (let i = 0; i < binary.length; i++) {
78
+ out[i] = binary.charCodeAt(i);
79
+ }
80
+ return out;
81
+ }
82
+ };
83
+ }
84
+ throw new Error(
85
+ "No Base64 implementation available. Provide a Base64Adapter via options."
86
+ );
87
+ }
88
+ function getDefaultCrypto() {
89
+ return globalThis.crypto;
90
+ }
91
+ function getDefaultFetch() {
92
+ return globalThis.fetch?.bind(globalThis);
93
+ }
94
+
95
+ // src/utils/base64.ts
96
+ var defaultAdapter = null;
97
+ function getAdapter(adapter) {
98
+ if (adapter) return adapter;
99
+ if (!defaultAdapter) {
100
+ defaultAdapter = getDefaultBase64();
101
+ }
102
+ return defaultAdapter;
103
+ }
104
+ function bytesToBase64(bytes, adapter) {
105
+ return getAdapter(adapter).encode(bytes);
106
+ }
107
+ function arrayBufferToBase64(buf, adapter) {
108
+ return bytesToBase64(new Uint8Array(buf), adapter);
109
+ }
110
+ function base64ToBytes(b64, adapter) {
111
+ return getAdapter(adapter).decode(b64);
112
+ }
113
+
114
+ // src/utils/lifetime.ts
115
+ var MULTIPLIERS = {
116
+ minutes: 60 * 1e3,
117
+ hours: 60 * 60 * 1e3,
118
+ days: 24 * 60 * 60 * 1e3
119
+ };
120
+ function lifetimeToMs(value, unit) {
121
+ const u = String(unit || "").toLowerCase();
122
+ const v = Number(value);
123
+ if (u === "unlimited") return 0;
124
+ if (!Number.isFinite(v) || v <= 0) return 0;
125
+ const m = MULTIPLIERS[u];
126
+ if (!m) return 0;
127
+ return Math.round(v * m);
128
+ }
129
+
130
+ // src/utils/semver.ts
131
+ function parseSemverMajorMinor(version) {
132
+ const parts = String(version || "").split(".").map((p) => Number(p));
133
+ const major = Number.isFinite(parts[0]) ? parts[0] : 0;
134
+ const minor = Number.isFinite(parts[1]) ? parts[1] : 0;
135
+ return { major, minor };
136
+ }
137
+
138
+ // src/utils/filename.ts
139
+ function validatePlainFilename(filename) {
140
+ if (typeof filename !== "string" || filename.trim().length === 0) {
141
+ throw new DropgateValidationError(
142
+ "Invalid filename. Must be a non-empty string."
143
+ );
144
+ }
145
+ if (filename.length > 255 || /[\/\\]/.test(filename)) {
146
+ throw new DropgateValidationError(
147
+ "Invalid filename. Contains illegal characters or is too long."
148
+ );
149
+ }
150
+ }
151
+
152
+ // src/utils/network.ts
153
+ function parseServerUrl(urlStr) {
154
+ let normalized = urlStr.trim();
155
+ if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
156
+ normalized = "https://" + normalized;
157
+ }
158
+ const url = new URL(normalized);
159
+ return {
160
+ host: url.hostname,
161
+ port: url.port ? Number(url.port) : void 0,
162
+ secure: url.protocol === "https:"
163
+ };
164
+ }
165
+ function buildBaseUrl(opts) {
166
+ const { host, port, secure } = opts;
167
+ if (!host || typeof host !== "string") {
168
+ throw new DropgateValidationError("Server host is required.");
169
+ }
170
+ const protocol = secure === false ? "http" : "https";
171
+ const portSuffix = port ? `:${port}` : "";
172
+ return `${protocol}://${host}${portSuffix}`;
173
+ }
174
+ function sleep(ms, signal) {
175
+ return new Promise((resolve, reject) => {
176
+ if (signal?.aborted) {
177
+ return reject(signal.reason || new DropgateAbortError());
178
+ }
179
+ const t = setTimeout(resolve, ms);
180
+ if (signal) {
181
+ signal.addEventListener(
182
+ "abort",
183
+ () => {
184
+ clearTimeout(t);
185
+ reject(signal.reason || new DropgateAbortError());
186
+ },
187
+ { once: true }
188
+ );
189
+ }
190
+ });
191
+ }
192
+ function makeAbortSignal(parentSignal, timeoutMs) {
193
+ const controller = new AbortController();
194
+ let timeoutId = null;
195
+ const abort = (reason) => {
196
+ if (!controller.signal.aborted) {
197
+ controller.abort(reason);
198
+ }
199
+ };
200
+ if (parentSignal) {
201
+ if (parentSignal.aborted) {
202
+ abort(parentSignal.reason);
203
+ } else {
204
+ parentSignal.addEventListener("abort", () => abort(parentSignal.reason), {
205
+ once: true
206
+ });
207
+ }
208
+ }
209
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
210
+ timeoutId = setTimeout(() => {
211
+ abort(new DropgateTimeoutError());
212
+ }, timeoutMs);
213
+ }
214
+ return {
215
+ signal: controller.signal,
216
+ cleanup: () => {
217
+ if (timeoutId) clearTimeout(timeoutId);
218
+ }
219
+ };
220
+ }
221
+ async function fetchJson(fetchFn, url, opts = {}) {
222
+ const { timeoutMs, signal, ...rest } = opts;
223
+ const { signal: s, cleanup } = makeAbortSignal(signal, timeoutMs);
224
+ try {
225
+ const res = await fetchFn(url, { ...rest, signal: s });
226
+ const text = await res.text();
227
+ let json = null;
228
+ try {
229
+ json = text ? JSON.parse(text) : null;
230
+ } catch {
231
+ }
232
+ return { res, json, text };
233
+ } finally {
234
+ cleanup();
235
+ }
236
+ }
237
+
238
+ // src/crypto/decrypt.ts
239
+ async function importKeyFromBase64(cryptoObj, keyB64, base64) {
240
+ const adapter = base64 || getDefaultBase64();
241
+ const keyBytes = adapter.decode(keyB64);
242
+ const keyBuffer = new Uint8Array(keyBytes).buffer;
243
+ return cryptoObj.subtle.importKey(
244
+ "raw",
245
+ keyBuffer,
246
+ { name: "AES-GCM" },
247
+ true,
248
+ ["decrypt"]
249
+ );
250
+ }
251
+ async function decryptChunk(cryptoObj, encryptedData, key) {
252
+ const iv = encryptedData.slice(0, AES_GCM_IV_BYTES);
253
+ const ciphertext = encryptedData.slice(AES_GCM_IV_BYTES);
254
+ return cryptoObj.subtle.decrypt(
255
+ { name: "AES-GCM", iv },
256
+ key,
257
+ ciphertext
258
+ );
259
+ }
260
+ async function decryptFilenameFromBase64(cryptoObj, encryptedFilenameB64, key, base64) {
261
+ const adapter = base64 || getDefaultBase64();
262
+ const encryptedBytes = adapter.decode(encryptedFilenameB64);
263
+ const decryptedBuffer = await decryptChunk(cryptoObj, encryptedBytes, key);
264
+ return new TextDecoder().decode(decryptedBuffer);
265
+ }
266
+
267
+ // src/crypto/index.ts
268
+ async function sha256Hex(cryptoObj, data) {
269
+ const hashBuffer = await cryptoObj.subtle.digest("SHA-256", data);
270
+ const arr = new Uint8Array(hashBuffer);
271
+ let hex = "";
272
+ for (let i = 0; i < arr.length; i++) {
273
+ hex += arr[i].toString(16).padStart(2, "0");
274
+ }
275
+ return hex;
276
+ }
277
+ async function generateAesGcmKey(cryptoObj) {
278
+ return cryptoObj.subtle.generateKey(
279
+ { name: "AES-GCM", length: 256 },
280
+ true,
281
+ ["encrypt", "decrypt"]
282
+ );
283
+ }
284
+ async function exportKeyBase64(cryptoObj, key) {
285
+ const raw = await cryptoObj.subtle.exportKey("raw", key);
286
+ return arrayBufferToBase64(raw);
287
+ }
288
+
289
+ // src/crypto/encrypt.ts
290
+ async function encryptToBlob(cryptoObj, dataBuffer, key) {
291
+ const iv = cryptoObj.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
292
+ const encrypted = await cryptoObj.subtle.encrypt(
293
+ { name: "AES-GCM", iv },
294
+ key,
295
+ dataBuffer
296
+ );
297
+ return new Blob([iv, new Uint8Array(encrypted)]);
298
+ }
299
+ async function encryptFilenameToBase64(cryptoObj, filename, key) {
300
+ const bytes = new TextEncoder().encode(String(filename));
301
+ const blob = await encryptToBlob(cryptoObj, bytes.buffer, key);
302
+ const buf = await blob.arrayBuffer();
303
+ return arrayBufferToBase64(buf);
304
+ }
305
+
306
+ // src/client/DropgateClient.ts
307
+ function estimateTotalUploadSizeBytes(fileSizeBytes, totalChunks, isEncrypted) {
308
+ const base = Number(fileSizeBytes) || 0;
309
+ if (!isEncrypted) return base;
310
+ return base + (Number(totalChunks) || 0) * ENCRYPTION_OVERHEAD_PER_CHUNK;
311
+ }
312
+ var DropgateClient = class {
313
+ /**
314
+ * Create a new DropgateClient instance.
315
+ * @param opts - Client configuration options.
316
+ * @throws {DropgateValidationError} If clientVersion is missing or invalid.
317
+ */
318
+ constructor(opts) {
319
+ if (!opts || typeof opts.clientVersion !== "string") {
320
+ throw new DropgateValidationError(
321
+ "DropgateClient requires clientVersion (string)."
322
+ );
323
+ }
324
+ this.clientVersion = opts.clientVersion;
325
+ this.chunkSize = Number.isFinite(opts.chunkSize) ? opts.chunkSize : DEFAULT_CHUNK_SIZE;
326
+ const fetchFn = opts.fetchFn || getDefaultFetch();
327
+ if (!fetchFn) {
328
+ throw new DropgateValidationError("No fetch() implementation found.");
329
+ }
330
+ this.fetchFn = fetchFn;
331
+ const cryptoObj = opts.cryptoObj || getDefaultCrypto();
332
+ if (!cryptoObj) {
333
+ throw new DropgateValidationError("No crypto implementation found.");
334
+ }
335
+ this.cryptoObj = cryptoObj;
336
+ this.base64 = opts.base64 || getDefaultBase64();
337
+ this.logger = opts.logger || null;
338
+ }
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
+ /**
374
+ * Resolve a user-entered sharing code or URL via the server.
375
+ * @param value - The sharing code or URL to resolve.
376
+ * @param opts - Server target and request options.
377
+ * @returns The resolved share target information.
378
+ * @throws {DropgateProtocolError} If the share lookup fails.
379
+ */
380
+ async resolveShareTarget(value, opts) {
381
+ const { host, port, secure, timeoutMs = 5e3, signal } = opts;
382
+ const baseUrl = buildBaseUrl({ host, port, secure });
383
+ const { res, json } = await fetchJson(
384
+ this.fetchFn,
385
+ `${baseUrl}/api/resolve`,
386
+ {
387
+ method: "POST",
388
+ timeoutMs,
389
+ signal,
390
+ headers: {
391
+ "Content-Type": "application/json",
392
+ Accept: "application/json"
393
+ },
394
+ body: JSON.stringify({ value })
395
+ }
396
+ );
397
+ if (!res.ok) {
398
+ const msg = (json && typeof json === "object" && "error" in json ? json.error : null) || `Share lookup failed (status ${res.status}).`;
399
+ throw new DropgateProtocolError(msg, { details: json });
400
+ }
401
+ return json || { valid: false, reason: "Unknown response." };
402
+ }
403
+ /**
404
+ * Check version compatibility between this client and a server.
405
+ * @param serverInfo - Server info containing the version to check against.
406
+ * @returns Compatibility result with status and message.
407
+ */
408
+ checkCompatibility(serverInfo) {
409
+ const serverVersion = String(serverInfo?.version || "0.0.0");
410
+ const clientVersion = String(this.clientVersion || "0.0.0");
411
+ const c = parseSemverMajorMinor(clientVersion);
412
+ const s = parseSemverMajorMinor(serverVersion);
413
+ if (c.major !== s.major) {
414
+ return {
415
+ compatible: false,
416
+ clientVersion,
417
+ serverVersion,
418
+ message: `Incompatible versions. Client v${clientVersion}, Server v${serverVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
419
+ };
420
+ }
421
+ if (c.minor > s.minor) {
422
+ return {
423
+ compatible: true,
424
+ clientVersion,
425
+ serverVersion,
426
+ message: `Client (v${clientVersion}) is newer than Server (v${serverVersion})${serverInfo?.name ? ` (${serverInfo.name})` : ""}. Some features may not work.`
427
+ };
428
+ }
429
+ return {
430
+ compatible: true,
431
+ clientVersion,
432
+ serverVersion,
433
+ message: `Server: v${serverVersion}, Client: v${clientVersion}${serverInfo?.name ? ` (${serverInfo.name})` : ""}.`
434
+ };
435
+ }
436
+ /**
437
+ * Validate file and upload settings against server capabilities.
438
+ * @param opts - Validation options containing file, settings, and server info.
439
+ * @returns True if validation passes.
440
+ * @throws {DropgateValidationError} If any validation check fails.
441
+ */
442
+ validateUploadInputs(opts) {
443
+ const { file, lifetimeMs, encrypt, serverInfo } = opts;
444
+ const caps = serverInfo?.capabilities?.upload;
445
+ if (!caps || !caps.enabled) {
446
+ throw new DropgateValidationError("Server does not support file uploads.");
447
+ }
448
+ const fileSize = Number(file?.size || 0);
449
+ if (!file || !Number.isFinite(fileSize) || fileSize <= 0) {
450
+ throw new DropgateValidationError("File is missing or invalid.");
451
+ }
452
+ const maxMB = Number(caps.maxSizeMB);
453
+ if (Number.isFinite(maxMB) && maxMB > 0) {
454
+ const limitBytes = maxMB * 1e3 * 1e3;
455
+ const totalChunks = Math.ceil(fileSize / this.chunkSize);
456
+ const estimatedBytes = estimateTotalUploadSizeBytes(
457
+ fileSize,
458
+ totalChunks,
459
+ Boolean(encrypt)
460
+ );
461
+ if (estimatedBytes > limitBytes) {
462
+ const msg = encrypt ? `File too large once encryption overhead is included. Server limit: ${maxMB} MB.` : `File too large. Server limit: ${maxMB} MB.`;
463
+ throw new DropgateValidationError(msg);
464
+ }
465
+ }
466
+ const maxHours = Number(caps.maxLifetimeHours);
467
+ const lt = Number(lifetimeMs);
468
+ if (!Number.isFinite(lt) || lt < 0 || !Number.isInteger(lt)) {
469
+ throw new DropgateValidationError(
470
+ "Invalid lifetime. Must be a non-negative integer (milliseconds)."
471
+ );
472
+ }
473
+ if (Number.isFinite(maxHours) && maxHours > 0) {
474
+ const limitMs = Math.round(maxHours * 60 * 60 * 1e3);
475
+ if (lt === 0) {
476
+ throw new DropgateValidationError(
477
+ `Server does not allow unlimited file lifetime. Max: ${maxHours} hours.`
478
+ );
479
+ }
480
+ if (lt > limitMs) {
481
+ throw new DropgateValidationError(
482
+ `File lifetime too long. Server limit: ${maxHours} hours.`
483
+ );
484
+ }
485
+ }
486
+ if (encrypt && !caps.e2ee) {
487
+ throw new DropgateValidationError(
488
+ "Server does not support end-to-end encryption."
489
+ );
490
+ }
491
+ return true;
492
+ }
493
+ /**
494
+ * Upload a file to the server with optional encryption.
495
+ * @param opts - Upload options including file, server target, and settings.
496
+ * @returns Upload result containing the download URL and file identifiers.
497
+ * @throws {DropgateValidationError} If input validation fails.
498
+ * @throws {DropgateNetworkError} If the server cannot be reached.
499
+ * @throws {DropgateProtocolError} If the server returns an error.
500
+ * @throws {DropgateAbortError} If the upload is cancelled.
501
+ */
502
+ async uploadFile(opts) {
503
+ const {
504
+ host,
505
+ port,
506
+ secure,
507
+ file,
508
+ lifetimeMs,
509
+ encrypt,
510
+ filenameOverride,
511
+ onProgress,
512
+ signal,
513
+ timeouts = {},
514
+ retry = {}
515
+ } = opts;
516
+ const progress = (evt) => {
517
+ try {
518
+ if (onProgress) onProgress(evt);
519
+ } catch {
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..." });
561
+ try {
562
+ cryptoKey = await generateAesGcmKey(this.cryptoObj);
563
+ keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
564
+ transmittedFilename = await encryptFilenameToBase64(
565
+ this.cryptoObj,
566
+ filename,
567
+ cryptoKey
568
+ );
569
+ } catch (err) {
570
+ throw new DropgateError("Failed to prepare encryption.", {
571
+ code: "CRYPTO_PREP_FAILED",
572
+ cause: err
573
+ });
574
+ }
575
+ }
576
+ const totalChunks = Math.ceil(file.size / this.chunkSize);
577
+ const totalUploadSize = estimateTotalUploadSizeBytes(
578
+ file.size,
579
+ totalChunks,
580
+ encrypt
581
+ );
582
+ progress({ phase: "init", text: "Reserving server storage..." });
583
+ const initPayload = {
584
+ filename: transmittedFilename,
585
+ lifetime: lifetimeMs,
586
+ isEncrypted: Boolean(encrypt),
587
+ totalSize: totalUploadSize,
588
+ totalChunks
589
+ };
590
+ const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
591
+ method: "POST",
592
+ timeoutMs: timeouts.initMs ?? 15e3,
593
+ signal,
594
+ headers: {
595
+ "Content-Type": "application/json",
596
+ Accept: "application/json"
597
+ },
598
+ body: JSON.stringify(initPayload)
599
+ });
600
+ if (!initRes.res.ok) {
601
+ const errorJson = initRes.json;
602
+ const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
603
+ throw new DropgateProtocolError(msg, {
604
+ details: initRes.json || initRes.text
605
+ });
606
+ }
607
+ const initJson = initRes.json;
608
+ const uploadId = initJson?.uploadId;
609
+ if (!uploadId || typeof uploadId !== "string") {
610
+ throw new DropgateProtocolError(
611
+ "Server did not return a valid uploadId."
612
+ );
613
+ }
614
+ const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
615
+ const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
616
+ const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
617
+ for (let i = 0; i < totalChunks; i++) {
618
+ if (signal?.aborted) {
619
+ throw signal.reason || new DropgateAbortError();
620
+ }
621
+ const start = i * this.chunkSize;
622
+ const end = Math.min(start + this.chunkSize, file.size);
623
+ let chunkBlob = file.slice(start, end);
624
+ const percentComplete = i / totalChunks * 100;
625
+ progress({
626
+ phase: "chunk",
627
+ text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
628
+ percent: percentComplete,
629
+ chunkIndex: i,
630
+ totalChunks
631
+ });
632
+ const chunkBuffer = await chunkBlob.arrayBuffer();
633
+ let uploadBlob;
634
+ if (encrypt && cryptoKey) {
635
+ uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
636
+ } else {
637
+ uploadBlob = new Blob([chunkBuffer]);
638
+ }
639
+ if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
640
+ throw new DropgateValidationError(
641
+ "Chunk too large (client-side). Check chunk size settings."
642
+ );
643
+ }
644
+ const toHash = await uploadBlob.arrayBuffer();
645
+ const hashHex = await sha256Hex(this.cryptoObj, toHash);
646
+ const headers = {
647
+ "Content-Type": "application/octet-stream",
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
+ {
656
+ method: "POST",
657
+ headers,
658
+ body: uploadBlob
659
+ },
660
+ {
661
+ retries,
662
+ backoffMs: baseBackoffMs,
663
+ maxBackoffMs,
664
+ timeoutMs: timeouts.chunkMs ?? 6e4,
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 })
685
+ }
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 });
706
+ return {
707
+ downloadUrl,
708
+ fileId,
709
+ uploadId,
710
+ baseUrl,
711
+ ...encrypt && keyB64 ? { keyB64 } : {}
712
+ };
713
+ }
714
+ /**
715
+ * Download a file from the server with optional decryption.
716
+ *
717
+ * **Important:** For large files, you must provide an `onData` callback to stream
718
+ * data incrementally. Without it, the entire file is buffered in memory, which will
719
+ * cause memory exhaustion for large files. Files exceeding 100MB without an `onData`
720
+ * callback will throw a validation error.
721
+ *
722
+ * @param opts - Download options including file ID, server target, and optional key.
723
+ * @param opts.onData - Streaming callback that receives data chunks. Required for files > 100MB.
724
+ * @returns Download result containing filename and received bytes.
725
+ * @throws {DropgateValidationError} If input validation fails or file is too large without onData.
726
+ * @throws {DropgateNetworkError} If the server cannot be reached.
727
+ * @throws {DropgateProtocolError} If the server returns an error.
728
+ * @throws {DropgateAbortError} If the download is cancelled.
729
+ */
730
+ async downloadFile(opts) {
731
+ const {
732
+ host,
733
+ port,
734
+ secure,
735
+ fileId,
736
+ keyB64,
737
+ onProgress,
738
+ onData,
739
+ signal,
740
+ timeoutMs = 6e4
741
+ } = opts;
742
+ const progress = (evt) => {
743
+ try {
744
+ if (onProgress) onProgress(evt);
745
+ } catch {
746
+ }
747
+ };
748
+ if (!fileId || typeof fileId !== "string") {
749
+ throw new DropgateValidationError("File ID is required.");
750
+ }
751
+ const baseUrl = buildBaseUrl({ host, port, secure });
752
+ progress({ phase: "metadata", text: "Fetching file info...", receivedBytes: 0, totalBytes: 0, percent: 0 });
753
+ const { signal: metaSignal, cleanup: metaCleanup } = makeAbortSignal(signal, timeoutMs);
754
+ let metadata;
755
+ try {
756
+ const metaRes = await this.fetchFn(`${baseUrl}/api/file/${fileId}/meta`, {
757
+ method: "GET",
758
+ headers: { Accept: "application/json" },
759
+ signal: metaSignal
760
+ });
761
+ if (!metaRes.ok) {
762
+ if (metaRes.status === 404) {
763
+ throw new DropgateProtocolError("File not found or has expired.");
764
+ }
765
+ throw new DropgateProtocolError(`Failed to fetch file metadata (status ${metaRes.status}).`);
766
+ }
767
+ metadata = await metaRes.json();
768
+ } catch (err) {
769
+ if (err instanceof DropgateError) throw err;
770
+ if (err instanceof Error && err.name === "AbortError") {
771
+ throw new DropgateAbortError("Download cancelled.");
772
+ }
773
+ throw new DropgateNetworkError("Could not fetch file metadata.", { cause: err });
774
+ } finally {
775
+ metaCleanup();
776
+ }
777
+ const isEncrypted = Boolean(metadata.isEncrypted);
778
+ const totalBytes = metadata.sizeBytes || 0;
779
+ if (!onData && totalBytes > MAX_IN_MEMORY_DOWNLOAD_BYTES) {
780
+ const sizeMB = Math.round(totalBytes / (1024 * 1024));
781
+ const limitMB = Math.round(MAX_IN_MEMORY_DOWNLOAD_BYTES / (1024 * 1024));
782
+ throw new DropgateValidationError(
783
+ `File is too large (${sizeMB}MB) to download without streaming. Provide an onData callback to stream files larger than ${limitMB}MB.`
784
+ );
785
+ }
786
+ let filename;
787
+ let cryptoKey;
788
+ if (isEncrypted) {
789
+ if (!keyB64) {
790
+ throw new DropgateValidationError("Decryption key is required for encrypted files.");
791
+ }
792
+ if (!this.cryptoObj?.subtle) {
793
+ throw new DropgateValidationError("Web Crypto API not available for decryption.");
794
+ }
795
+ progress({ phase: "decrypting", text: "Preparing decryption...", receivedBytes: 0, totalBytes: 0, percent: 0 });
796
+ try {
797
+ cryptoKey = await importKeyFromBase64(this.cryptoObj, keyB64, this.base64);
798
+ filename = await decryptFilenameFromBase64(
799
+ this.cryptoObj,
800
+ metadata.encryptedFilename,
801
+ cryptoKey,
802
+ this.base64
803
+ );
804
+ } catch (err) {
805
+ throw new DropgateError("Failed to decrypt filename. Invalid key or corrupted data.", {
806
+ code: "DECRYPT_FILENAME_FAILED",
807
+ cause: err
808
+ });
809
+ }
810
+ } else {
811
+ filename = metadata.filename || "file";
812
+ }
813
+ progress({ phase: "downloading", text: "Starting download...", percent: 0, receivedBytes: 0, totalBytes });
814
+ const { signal: downloadSignal, cleanup: downloadCleanup } = makeAbortSignal(signal, timeoutMs);
815
+ let receivedBytes = 0;
816
+ const dataChunks = [];
817
+ const collectData = !onData;
818
+ try {
819
+ const downloadRes = await this.fetchFn(`${baseUrl}/api/file/${fileId}`, {
820
+ method: "GET",
821
+ signal: downloadSignal
822
+ });
823
+ if (!downloadRes.ok) {
824
+ throw new DropgateProtocolError(`Download failed (status ${downloadRes.status}).`);
825
+ }
826
+ if (!downloadRes.body) {
827
+ throw new DropgateProtocolError("Streaming response not available.");
828
+ }
829
+ const reader = downloadRes.body.getReader();
830
+ if (isEncrypted && cryptoKey) {
831
+ const ENCRYPTED_CHUNK_SIZE = this.chunkSize + ENCRYPTION_OVERHEAD_PER_CHUNK;
832
+ const pendingChunks = [];
833
+ let pendingLength = 0;
834
+ const flushPending = () => {
835
+ if (pendingChunks.length === 0) return new Uint8Array(0);
836
+ if (pendingChunks.length === 1) {
837
+ const result2 = pendingChunks[0];
838
+ pendingChunks.length = 0;
839
+ pendingLength = 0;
840
+ return result2;
841
+ }
842
+ const result = new Uint8Array(pendingLength);
843
+ let offset = 0;
844
+ for (const chunk of pendingChunks) {
845
+ result.set(chunk, offset);
846
+ offset += chunk.length;
847
+ }
848
+ pendingChunks.length = 0;
849
+ pendingLength = 0;
850
+ return result;
851
+ };
852
+ while (true) {
853
+ if (signal?.aborted) {
854
+ throw new DropgateAbortError("Download cancelled.");
855
+ }
856
+ const { done, value } = await reader.read();
857
+ if (done) break;
858
+ pendingChunks.push(value);
859
+ pendingLength += value.length;
860
+ while (pendingLength >= ENCRYPTED_CHUNK_SIZE) {
861
+ const buffer = flushPending();
862
+ const encryptedChunk = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
863
+ if (buffer.length > ENCRYPTED_CHUNK_SIZE) {
864
+ const remainder = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
865
+ pendingChunks.push(remainder);
866
+ pendingLength = remainder.length;
867
+ }
868
+ const decryptedBuffer = await decryptChunk(this.cryptoObj, encryptedChunk, cryptoKey);
869
+ const decryptedData = new Uint8Array(decryptedBuffer);
870
+ if (collectData) {
871
+ dataChunks.push(decryptedData);
872
+ } else {
873
+ await onData(decryptedData);
874
+ }
875
+ }
876
+ receivedBytes += value.length;
877
+ const percent = totalBytes > 0 ? Math.round(receivedBytes / totalBytes * 100) : 0;
878
+ progress({
879
+ phase: "decrypting",
880
+ text: `Downloading & decrypting... (${percent}%)`,
881
+ percent,
882
+ receivedBytes,
883
+ totalBytes
884
+ });
885
+ }
886
+ if (pendingLength > 0) {
887
+ const buffer = flushPending();
888
+ const decryptedBuffer = await decryptChunk(this.cryptoObj, buffer, cryptoKey);
889
+ const decryptedData = new Uint8Array(decryptedBuffer);
890
+ if (collectData) {
891
+ dataChunks.push(decryptedData);
892
+ } else {
893
+ await onData(decryptedData);
894
+ }
895
+ }
896
+ } else {
897
+ while (true) {
898
+ if (signal?.aborted) {
899
+ throw new DropgateAbortError("Download cancelled.");
900
+ }
901
+ const { done, value } = await reader.read();
902
+ if (done) break;
903
+ if (collectData) {
904
+ dataChunks.push(value);
905
+ } else {
906
+ await onData(value);
907
+ }
908
+ receivedBytes += value.length;
909
+ const percent = totalBytes > 0 ? Math.round(receivedBytes / totalBytes * 100) : 0;
910
+ progress({
911
+ phase: "downloading",
912
+ text: `Downloading... (${percent}%)`,
913
+ percent,
914
+ receivedBytes,
915
+ totalBytes
916
+ });
917
+ }
918
+ }
919
+ } catch (err) {
920
+ if (err instanceof DropgateError) throw err;
921
+ if (err instanceof Error && err.name === "AbortError") {
922
+ throw new DropgateAbortError("Download cancelled.");
923
+ }
924
+ throw new DropgateNetworkError("Download failed.", { cause: err });
925
+ } finally {
926
+ downloadCleanup();
927
+ }
928
+ progress({ phase: "complete", text: "Download complete!", percent: 100, receivedBytes, totalBytes });
929
+ let data;
930
+ if (collectData && dataChunks.length > 0) {
931
+ const totalLength = dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
932
+ data = new Uint8Array(totalLength);
933
+ let offset = 0;
934
+ for (const chunk of dataChunks) {
935
+ data.set(chunk, offset);
936
+ offset += chunk.length;
937
+ }
938
+ }
939
+ return {
940
+ filename,
941
+ receivedBytes,
942
+ wasEncrypted: isEncrypted,
943
+ ...data ? { data } : {}
944
+ };
945
+ }
946
+ async attemptChunkUpload(url, fetchOptions, opts) {
947
+ const {
948
+ retries,
949
+ backoffMs,
950
+ maxBackoffMs,
951
+ timeoutMs,
952
+ signal,
953
+ progress,
954
+ chunkIndex,
955
+ totalChunks
956
+ } = opts;
957
+ let attemptsLeft = retries;
958
+ let currentBackoff = backoffMs;
959
+ const maxRetries = retries;
960
+ while (true) {
961
+ if (signal?.aborted) {
962
+ throw signal.reason || new DropgateAbortError();
963
+ }
964
+ const { signal: s, cleanup } = makeAbortSignal(signal, timeoutMs);
965
+ try {
966
+ const res = await this.fetchFn(url, { ...fetchOptions, signal: s });
967
+ if (res.ok) return;
968
+ const text = await res.text().catch(() => "");
969
+ const err = new DropgateProtocolError(
970
+ `Chunk ${chunkIndex + 1} failed (HTTP ${res.status}).`,
971
+ {
972
+ details: { status: res.status, bodySnippet: text.slice(0, 120) }
973
+ }
974
+ );
975
+ throw err;
976
+ } catch (err) {
977
+ cleanup();
978
+ if (err instanceof Error && (err.name === "AbortError" || err.code === "ABORT_ERR")) {
979
+ throw err;
980
+ }
981
+ if (signal?.aborted) {
982
+ throw signal.reason || new DropgateAbortError();
983
+ }
984
+ if (attemptsLeft <= 0) {
985
+ throw err instanceof DropgateError ? err : new DropgateNetworkError("Chunk upload failed.", { cause: err });
986
+ }
987
+ const attemptNumber = maxRetries - attemptsLeft + 1;
988
+ let remaining = currentBackoff;
989
+ const tick = 100;
990
+ while (remaining > 0) {
991
+ const secondsLeft = (remaining / 1e3).toFixed(1);
992
+ progress({
993
+ phase: "retry-wait",
994
+ text: `Chunk upload failed. Retrying in ${secondsLeft}s... (${attemptNumber}/${maxRetries})`,
995
+ chunkIndex,
996
+ totalChunks
997
+ });
998
+ await sleep(Math.min(tick, remaining), signal);
999
+ remaining -= tick;
1000
+ }
1001
+ progress({
1002
+ phase: "retry",
1003
+ text: `Chunk upload failed. Retrying now... (${attemptNumber}/${maxRetries})`,
1004
+ chunkIndex,
1005
+ totalChunks
1006
+ });
1007
+ attemptsLeft -= 1;
1008
+ currentBackoff = Math.min(currentBackoff * 2, maxBackoffMs);
1009
+ continue;
1010
+ } finally {
1011
+ cleanup();
1012
+ }
1013
+ }
1014
+ }
1015
+ };
1016
+
1017
+ // src/p2p/utils.ts
1018
+ function isLocalhostHostname(hostname) {
1019
+ const host = String(hostname || "").toLowerCase();
1020
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
1021
+ }
1022
+ function isSecureContextForP2P(hostname, isSecureContext) {
1023
+ return Boolean(isSecureContext) || isLocalhostHostname(hostname || "");
1024
+ }
1025
+ function generateP2PCode(cryptoObj) {
1026
+ const crypto = cryptoObj || getDefaultCrypto();
1027
+ const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ";
1028
+ if (crypto) {
1029
+ const randomBytes = new Uint8Array(8);
1030
+ crypto.getRandomValues(randomBytes);
1031
+ let letterPart = "";
1032
+ for (let i = 0; i < 4; i++) {
1033
+ letterPart += letters[randomBytes[i] % letters.length];
1034
+ }
1035
+ let numberPart = "";
1036
+ for (let i = 4; i < 8; i++) {
1037
+ numberPart += (randomBytes[i] % 10).toString();
1038
+ }
1039
+ return `${letterPart}-${numberPart}`;
1040
+ }
1041
+ let a = "";
1042
+ for (let i = 0; i < 4; i++) {
1043
+ a += letters[Math.floor(Math.random() * letters.length)];
1044
+ }
1045
+ let b = "";
1046
+ for (let i = 0; i < 4; i++) {
1047
+ b += Math.floor(Math.random() * 10);
1048
+ }
1049
+ return `${a}-${b}`;
1050
+ }
1051
+ function isP2PCodeLike(code) {
1052
+ return /^[A-Z]{4}-\d{4}$/.test(String(code || "").trim());
1053
+ }
1054
+
1055
+ // src/p2p/helpers.ts
1056
+ function buildPeerOptions(opts = {}) {
1057
+ const { host, port, peerjsPath = "/peerjs", secure = false, iceServers = [] } = opts;
1058
+ const peerOpts = {
1059
+ host,
1060
+ path: peerjsPath,
1061
+ secure,
1062
+ config: { iceServers },
1063
+ debug: 0
1064
+ };
1065
+ if (port) {
1066
+ peerOpts.port = port;
1067
+ }
1068
+ return peerOpts;
1069
+ }
1070
+ async function createPeerWithRetries(opts) {
1071
+ const { code, codeGenerator, maxAttempts, buildPeer, onCode } = opts;
1072
+ let nextCode = code || codeGenerator();
1073
+ let peer = null;
1074
+ let lastError = null;
1075
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1076
+ onCode?.(nextCode, attempt);
1077
+ try {
1078
+ peer = await new Promise((resolve, reject) => {
1079
+ const instance = buildPeer(nextCode);
1080
+ instance.on("open", () => resolve(instance));
1081
+ instance.on("error", (err) => {
1082
+ try {
1083
+ instance.destroy();
1084
+ } catch {
1085
+ }
1086
+ reject(err);
1087
+ });
1088
+ });
1089
+ return { peer, code: nextCode };
1090
+ } catch (err) {
1091
+ lastError = err;
1092
+ nextCode = codeGenerator();
1093
+ }
1094
+ }
1095
+ throw lastError || new DropgateNetworkError("Could not establish PeerJS connection.");
1096
+ }
1097
+
1098
+ // src/p2p/send.ts
1099
+ async function startP2PSend(opts) {
1100
+ const {
1101
+ file,
1102
+ Peer,
1103
+ serverInfo,
1104
+ host,
1105
+ port,
1106
+ peerjsPath,
1107
+ secure = false,
1108
+ iceServers,
1109
+ codeGenerator,
1110
+ cryptoObj,
1111
+ maxAttempts = 4,
1112
+ chunkSize = 256 * 1024,
1113
+ readyTimeoutMs = 8e3,
1114
+ endAckTimeoutMs = 15e3,
1115
+ bufferHighWaterMark = 8 * 1024 * 1024,
1116
+ bufferLowWaterMark = 2 * 1024 * 1024,
1117
+ onCode,
1118
+ onStatus,
1119
+ onProgress,
1120
+ onComplete,
1121
+ onError
1122
+ } = opts;
1123
+ if (!file) {
1124
+ throw new DropgateValidationError("File is missing.");
1125
+ }
1126
+ if (!Peer) {
1127
+ throw new DropgateValidationError(
1128
+ "PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
1129
+ );
1130
+ }
1131
+ const p2pCaps = serverInfo?.capabilities?.p2p;
1132
+ if (serverInfo && !p2pCaps?.enabled) {
1133
+ throw new DropgateValidationError("Direct transfer is disabled on this server.");
1134
+ }
1135
+ const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1136
+ const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1137
+ const peerOpts = buildPeerOptions({
1138
+ host,
1139
+ port,
1140
+ peerjsPath: finalPath,
1141
+ secure,
1142
+ iceServers: finalIceServers
1143
+ });
1144
+ const finalCodeGenerator = codeGenerator || (() => generateP2PCode(cryptoObj));
1145
+ const buildPeer = (id) => new Peer(id, peerOpts);
1146
+ const { peer, code } = await createPeerWithRetries({
1147
+ code: null,
1148
+ codeGenerator: finalCodeGenerator,
1149
+ maxAttempts,
1150
+ buildPeer,
1151
+ onCode
1152
+ });
1153
+ let stopped = false;
1154
+ let activeConn = null;
1155
+ let transferActive = false;
1156
+ let transferCompleted = false;
1157
+ const reportProgress = (data) => {
1158
+ const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
1159
+ const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
1160
+ const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
1161
+ onProgress?.({ sent: safeReceived, total: safeTotal, percent });
1162
+ };
1163
+ const stop = () => {
1164
+ stopped = true;
1165
+ try {
1166
+ activeConn?.close();
1167
+ } catch {
1168
+ }
1169
+ try {
1170
+ peer.destroy();
1171
+ } catch {
1172
+ }
1173
+ };
1174
+ peer.on("connection", (conn) => {
1175
+ if (stopped) return;
1176
+ if (activeConn) {
1177
+ try {
1178
+ conn.send({ t: "error", message: "Another receiver is already connected." });
1179
+ } catch {
1180
+ }
1181
+ try {
1182
+ conn.close();
1183
+ } catch {
1184
+ }
1185
+ return;
1186
+ }
1187
+ activeConn = conn;
1188
+ onStatus?.({ phase: "connected", message: "Connected. Starting transfer..." });
1189
+ let readyResolve = null;
1190
+ let ackResolve = null;
1191
+ const readyPromise = new Promise((resolve) => {
1192
+ readyResolve = resolve;
1193
+ });
1194
+ const ackPromise = new Promise((resolve) => {
1195
+ ackResolve = resolve;
1196
+ });
1197
+ conn.on("data", (data) => {
1198
+ if (!data || typeof data !== "object" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
1199
+ return;
1200
+ }
1201
+ const msg = data;
1202
+ if (!msg.t) return;
1203
+ if (msg.t === "ready") {
1204
+ readyResolve?.();
1205
+ return;
1206
+ }
1207
+ if (msg.t === "progress") {
1208
+ reportProgress({ received: msg.received || 0, total: msg.total || 0 });
1209
+ return;
1210
+ }
1211
+ if (msg.t === "ack" && msg.phase === "end") {
1212
+ ackResolve?.(msg);
1213
+ return;
1214
+ }
1215
+ if (msg.t === "error") {
1216
+ onError?.(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1217
+ stop();
1218
+ }
1219
+ });
1220
+ conn.on("open", async () => {
1221
+ try {
1222
+ transferActive = true;
1223
+ if (stopped) return;
1224
+ conn.send({
1225
+ t: "meta",
1226
+ name: file.name,
1227
+ size: file.size,
1228
+ mime: file.type || "application/octet-stream"
1229
+ });
1230
+ let sent = 0;
1231
+ const total = file.size;
1232
+ const dc = conn._dc;
1233
+ if (dc && Number.isFinite(bufferLowWaterMark)) {
1234
+ try {
1235
+ dc.bufferedAmountLowThreshold = bufferLowWaterMark;
1236
+ } catch {
1237
+ }
1238
+ }
1239
+ await Promise.race([readyPromise, sleep(readyTimeoutMs).catch(() => null)]);
1240
+ for (let offset = 0; offset < total; offset += chunkSize) {
1241
+ if (stopped) return;
1242
+ const slice = file.slice(offset, offset + chunkSize);
1243
+ const buf = await slice.arrayBuffer();
1244
+ conn.send(buf);
1245
+ sent += buf.byteLength;
1246
+ if (dc) {
1247
+ while (dc.bufferedAmount > bufferHighWaterMark) {
1248
+ await new Promise((resolve) => {
1249
+ const fallback = setTimeout(resolve, 60);
1250
+ try {
1251
+ dc.addEventListener(
1252
+ "bufferedamountlow",
1253
+ () => {
1254
+ clearTimeout(fallback);
1255
+ resolve();
1256
+ },
1257
+ { once: true }
1258
+ );
1259
+ } catch {
1260
+ }
1261
+ });
1262
+ }
1263
+ }
1264
+ }
1265
+ if (stopped) return;
1266
+ conn.send({ t: "end" });
1267
+ const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
1268
+ const ackResult = await Promise.race([
1269
+ ackPromise,
1270
+ sleep(ackTimeoutMs || 15e3).catch(() => null)
1271
+ ]);
1272
+ if (!ackResult || typeof ackResult !== "object") {
1273
+ throw new DropgateNetworkError("Receiver did not confirm completion.");
1274
+ }
1275
+ const ackData = ackResult;
1276
+ const ackTotal = Number(ackData.total) || file.size;
1277
+ const ackReceived = Number(ackData.received) || 0;
1278
+ if (ackTotal && ackReceived < ackTotal) {
1279
+ throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
1280
+ }
1281
+ reportProgress({ received: ackReceived || ackTotal, total: ackTotal });
1282
+ transferCompleted = true;
1283
+ transferActive = false;
1284
+ onComplete?.();
1285
+ stop();
1286
+ } catch (err) {
1287
+ onError?.(err);
1288
+ stop();
1289
+ }
1290
+ });
1291
+ conn.on("error", (err) => {
1292
+ onError?.(err);
1293
+ stop();
1294
+ });
1295
+ conn.on("close", () => {
1296
+ if (!transferCompleted && transferActive && !stopped) {
1297
+ onError?.(
1298
+ new DropgateNetworkError("Receiver disconnected before transfer completed.")
1299
+ );
1300
+ }
1301
+ stop();
1302
+ });
1303
+ });
1304
+ return { peer, code, stop };
1305
+ }
1306
+
1307
+ // src/p2p/receive.ts
1308
+ async function startP2PReceive(opts) {
1309
+ const {
1310
+ code,
1311
+ Peer,
1312
+ serverInfo,
1313
+ host,
1314
+ port,
1315
+ peerjsPath,
1316
+ secure = false,
1317
+ iceServers,
1318
+ onStatus,
1319
+ onMeta,
1320
+ onData,
1321
+ onProgress,
1322
+ onComplete,
1323
+ onError,
1324
+ onDisconnect
1325
+ } = opts;
1326
+ if (!code) {
1327
+ throw new DropgateValidationError("No sharing code was provided.");
1328
+ }
1329
+ if (!Peer) {
1330
+ throw new DropgateValidationError(
1331
+ "PeerJS Peer constructor is required. Install peerjs and pass it as the Peer option."
1332
+ );
1333
+ }
1334
+ const p2pCaps = serverInfo?.capabilities?.p2p;
1335
+ if (serverInfo && !p2pCaps?.enabled) {
1336
+ throw new DropgateValidationError("Direct transfer is disabled on this server.");
1337
+ }
1338
+ const normalizedCode = String(code).trim().replace(/\s+/g, "").toUpperCase();
1339
+ if (!isP2PCodeLike(normalizedCode)) {
1340
+ throw new DropgateValidationError("Invalid direct transfer code.");
1341
+ }
1342
+ const finalPath = peerjsPath ?? p2pCaps?.peerjsPath ?? "/peerjs";
1343
+ const finalIceServers = iceServers ?? p2pCaps?.iceServers ?? [];
1344
+ const peerOpts = buildPeerOptions({
1345
+ host,
1346
+ port,
1347
+ peerjsPath: finalPath,
1348
+ secure,
1349
+ iceServers: finalIceServers
1350
+ });
1351
+ const peer = new Peer(void 0, peerOpts);
1352
+ let total = 0;
1353
+ let received = 0;
1354
+ let lastProgressSentAt = 0;
1355
+ const progressIntervalMs = 120;
1356
+ let writeQueue = Promise.resolve();
1357
+ const stop = () => {
1358
+ try {
1359
+ peer.destroy();
1360
+ } catch {
1361
+ }
1362
+ };
1363
+ peer.on("error", (err) => {
1364
+ onError?.(err);
1365
+ stop();
1366
+ });
1367
+ peer.on("open", () => {
1368
+ const conn = peer.connect(normalizedCode, { reliable: true });
1369
+ conn.on("open", () => {
1370
+ onStatus?.({ phase: "connected", message: "Waiting for file details..." });
1371
+ });
1372
+ conn.on("data", async (data) => {
1373
+ try {
1374
+ if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
1375
+ const msg = data;
1376
+ if (msg.t === "meta") {
1377
+ const name = String(msg.name || "file");
1378
+ total = Number(msg.size) || 0;
1379
+ received = 0;
1380
+ writeQueue = Promise.resolve();
1381
+ onMeta?.({ name, total });
1382
+ onProgress?.({ received, total, percent: 0 });
1383
+ try {
1384
+ conn.send({ t: "ready" });
1385
+ } catch {
1386
+ }
1387
+ return;
1388
+ }
1389
+ if (msg.t === "end") {
1390
+ await writeQueue;
1391
+ if (total && received < total) {
1392
+ const err = new DropgateNetworkError(
1393
+ "Transfer ended before the full file was received."
1394
+ );
1395
+ try {
1396
+ conn.send({ t: "error", message: err.message });
1397
+ } catch {
1398
+ }
1399
+ throw err;
1400
+ }
1401
+ onComplete?.({ received, total });
1402
+ try {
1403
+ conn.send({ t: "ack", phase: "end", received, total });
1404
+ } catch {
1405
+ }
1406
+ return;
1407
+ }
1408
+ if (msg.t === "error") {
1409
+ throw new DropgateNetworkError(msg.message || "Sender reported an error.");
1410
+ }
1411
+ return;
1412
+ }
1413
+ let bufPromise;
1414
+ if (data instanceof ArrayBuffer) {
1415
+ bufPromise = Promise.resolve(new Uint8Array(data));
1416
+ } else if (ArrayBuffer.isView(data)) {
1417
+ bufPromise = Promise.resolve(
1418
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
1419
+ );
1420
+ } else if (typeof Blob !== "undefined" && data instanceof Blob) {
1421
+ bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
1422
+ } else {
1423
+ return;
1424
+ }
1425
+ writeQueue = writeQueue.then(async () => {
1426
+ const buf = await bufPromise;
1427
+ if (onData) {
1428
+ await onData(buf);
1429
+ }
1430
+ received += buf.byteLength;
1431
+ const percent = total ? Math.min(100, received / total * 100) : 0;
1432
+ onProgress?.({ received, total, percent });
1433
+ const now = Date.now();
1434
+ if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
1435
+ lastProgressSentAt = now;
1436
+ try {
1437
+ conn.send({ t: "progress", received, total });
1438
+ } catch {
1439
+ }
1440
+ }
1441
+ }).catch((err) => {
1442
+ try {
1443
+ conn.send({
1444
+ t: "error",
1445
+ message: err?.message || "Receiver write failed."
1446
+ });
1447
+ } catch {
1448
+ }
1449
+ onError?.(err);
1450
+ stop();
1451
+ });
1452
+ } catch (err) {
1453
+ onError?.(err);
1454
+ stop();
1455
+ }
1456
+ });
1457
+ conn.on("close", () => {
1458
+ if (received > 0 && total > 0 && received < total) {
1459
+ onDisconnect?.();
1460
+ }
1461
+ });
1462
+ });
1463
+ return { peer, stop };
1464
+ }
1465
+ export {
1466
+ AES_GCM_IV_BYTES,
1467
+ AES_GCM_TAG_BYTES,
1468
+ DEFAULT_CHUNK_SIZE,
1469
+ DropgateAbortError,
1470
+ DropgateClient,
1471
+ DropgateError,
1472
+ DropgateNetworkError,
1473
+ DropgateProtocolError,
1474
+ DropgateTimeoutError,
1475
+ DropgateValidationError,
1476
+ ENCRYPTION_OVERHEAD_PER_CHUNK,
1477
+ arrayBufferToBase64,
1478
+ base64ToBytes,
1479
+ buildBaseUrl,
1480
+ buildPeerOptions,
1481
+ bytesToBase64,
1482
+ createPeerWithRetries,
1483
+ decryptChunk,
1484
+ decryptFilenameFromBase64,
1485
+ encryptFilenameToBase64,
1486
+ encryptToBlob,
1487
+ estimateTotalUploadSizeBytes,
1488
+ exportKeyBase64,
1489
+ fetchJson,
1490
+ generateAesGcmKey,
1491
+ generateP2PCode,
1492
+ getDefaultBase64,
1493
+ getDefaultCrypto,
1494
+ getDefaultFetch,
1495
+ importKeyFromBase64,
1496
+ isLocalhostHostname,
1497
+ isP2PCodeLike,
1498
+ isSecureContextForP2P,
1499
+ lifetimeToMs,
1500
+ makeAbortSignal,
1501
+ parseSemverMajorMinor,
1502
+ parseServerUrl,
1503
+ sha256Hex,
1504
+ sleep,
1505
+ startP2PReceive,
1506
+ startP2PSend,
1507
+ validatePlainFilename
1508
+ };
1509
+ //# sourceMappingURL=index.js.map