@dropgate/core 2.2.1 → 3.0.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.
@@ -3,6 +3,7 @@ var __defProp = Object.defineProperty;
3
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
7
  var __export = (target, all) => {
7
8
  for (var name in all)
8
9
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -16,15 +17,23 @@ var __copyProps = (to, from, except, desc) => {
16
17
  return to;
17
18
  };
18
19
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
19
21
 
20
22
  // src/p2p/index.ts
21
23
  var p2p_exports = {};
22
24
  __export(p2p_exports, {
25
+ P2P_CHUNK_SIZE: () => P2P_CHUNK_SIZE,
26
+ P2P_END_ACK_RETRIES: () => P2P_END_ACK_RETRIES,
27
+ P2P_END_ACK_TIMEOUT_MS: () => P2P_END_ACK_TIMEOUT_MS,
28
+ P2P_MAX_UNACKED_CHUNKS: () => P2P_MAX_UNACKED_CHUNKS,
29
+ P2P_PROTOCOL_VERSION: () => P2P_PROTOCOL_VERSION,
23
30
  buildPeerOptions: () => buildPeerOptions,
24
31
  createPeerWithRetries: () => createPeerWithRetries,
25
32
  generateP2PCode: () => generateP2PCode,
26
33
  isLocalhostHostname: () => isLocalhostHostname,
27
34
  isP2PCodeLike: () => isP2PCodeLike,
35
+ isP2PMessage: () => isP2PMessage,
36
+ isProtocolCompatible: () => isProtocolCompatible,
28
37
  isSecureContextForP2P: () => isSecureContextForP2P,
29
38
  resolvePeerConfig: () => resolvePeerConfig,
30
39
  startP2PReceive: () => startP2PReceive,
@@ -35,18 +44,12 @@ module.exports = __toCommonJS(p2p_exports);
35
44
  // src/errors.ts
36
45
  var DropgateError = class extends Error {
37
46
  constructor(message, opts = {}) {
38
- super(message);
47
+ super(message, opts.cause !== void 0 ? { cause: opts.cause } : void 0);
48
+ __publicField(this, "code");
49
+ __publicField(this, "details");
39
50
  this.name = this.constructor.name;
40
51
  this.code = opts.code || "DROPGATE_ERROR";
41
52
  this.details = opts.details;
42
- if (opts.cause !== void 0) {
43
- Object.defineProperty(this, "cause", {
44
- value: opts.cause,
45
- writable: false,
46
- enumerable: false,
47
- configurable: true
48
- });
49
- }
50
53
  }
51
54
  };
52
55
  var DropgateValidationError = class extends DropgateError {
@@ -178,13 +181,57 @@ async function createPeerWithRetries(opts) {
178
181
  throw lastError || new DropgateNetworkError("Could not establish PeerJS connection.");
179
182
  }
180
183
 
184
+ // src/p2p/protocol.ts
185
+ var P2P_PROTOCOL_VERSION = 3;
186
+ function isP2PMessage(value) {
187
+ if (!value || typeof value !== "object") return false;
188
+ const msg = value;
189
+ return typeof msg.t === "string" && [
190
+ "hello",
191
+ "file_list",
192
+ "meta",
193
+ "ready",
194
+ "chunk",
195
+ "chunk_ack",
196
+ "file_end",
197
+ "file_end_ack",
198
+ "end",
199
+ "end_ack",
200
+ "ping",
201
+ "pong",
202
+ "error",
203
+ "cancelled",
204
+ "resume",
205
+ "resume_ack"
206
+ ].includes(msg.t);
207
+ }
208
+ function isProtocolCompatible(senderVersion, receiverVersion) {
209
+ return senderVersion === receiverVersion;
210
+ }
211
+ var P2P_CHUNK_SIZE = 64 * 1024;
212
+ var P2P_MAX_UNACKED_CHUNKS = 32;
213
+ var P2P_END_ACK_TIMEOUT_MS = 15e3;
214
+ var P2P_END_ACK_RETRIES = 3;
215
+ var P2P_END_ACK_RETRY_DELAY_MS = 100;
216
+ var P2P_CLOSE_GRACE_PERIOD_MS = 2e3;
217
+
181
218
  // src/p2p/send.ts
219
+ var P2P_UNACKED_CHUNK_TIMEOUT_MS = 3e4;
182
220
  function generateSessionId() {
183
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
184
- return crypto.randomUUID();
185
- }
186
- return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
221
+ return crypto.randomUUID();
187
222
  }
223
+ var ALLOWED_TRANSITIONS = {
224
+ initializing: ["listening", "closed"],
225
+ listening: ["handshaking", "closed", "cancelled"],
226
+ handshaking: ["negotiating", "closed", "cancelled"],
227
+ negotiating: ["transferring", "closed", "cancelled"],
228
+ transferring: ["finishing", "closed", "cancelled"],
229
+ finishing: ["awaiting_ack", "closed", "cancelled"],
230
+ awaiting_ack: ["completed", "closed", "cancelled"],
231
+ completed: ["closed"],
232
+ cancelled: ["closed"],
233
+ closed: []
234
+ };
188
235
  async function startP2PSend(opts) {
189
236
  const {
190
237
  file,
@@ -198,21 +245,27 @@ async function startP2PSend(opts) {
198
245
  codeGenerator,
199
246
  cryptoObj,
200
247
  maxAttempts = 4,
201
- chunkSize = 256 * 1024,
202
- endAckTimeoutMs = 15e3,
248
+ chunkSize = P2P_CHUNK_SIZE,
249
+ endAckTimeoutMs = P2P_END_ACK_TIMEOUT_MS,
203
250
  bufferHighWaterMark = 8 * 1024 * 1024,
204
251
  bufferLowWaterMark = 2 * 1024 * 1024,
205
252
  heartbeatIntervalMs = 5e3,
253
+ chunkAcknowledgments = true,
254
+ maxUnackedChunks = P2P_MAX_UNACKED_CHUNKS,
206
255
  onCode,
207
256
  onStatus,
208
257
  onProgress,
209
258
  onComplete,
210
259
  onError,
211
260
  onDisconnect,
212
- onCancel
261
+ onCancel,
262
+ onConnectionHealth
213
263
  } = opts;
214
- if (!file) {
215
- throw new DropgateValidationError("File is missing.");
264
+ const files = Array.isArray(file) ? file : [file];
265
+ const isMultiFile = files.length > 1;
266
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
267
+ if (!files.length) {
268
+ throw new DropgateValidationError("At least one file is required.");
216
269
  }
217
270
  if (!Peer) {
218
271
  throw new DropgateValidationError(
@@ -248,21 +301,39 @@ async function startP2PSend(opts) {
248
301
  let activeConn = null;
249
302
  let sentBytes = 0;
250
303
  let heartbeatTimer = null;
304
+ let healthCheckTimer = null;
305
+ let lastActivityTime = Date.now();
306
+ const unackedChunks = /* @__PURE__ */ new Map();
307
+ let nextSeq = 0;
308
+ let ackResolvers = [];
309
+ let transferEverStarted = false;
310
+ const connectionAttempts = [];
311
+ const MAX_CONNECTION_ATTEMPTS = 10;
312
+ const CONNECTION_RATE_WINDOW_MS = 1e4;
313
+ const transitionTo = (newState) => {
314
+ if (!ALLOWED_TRANSITIONS[state].includes(newState)) {
315
+ console.warn(`[P2P Send] Invalid state transition: ${state} -> ${newState}`);
316
+ return false;
317
+ }
318
+ state = newState;
319
+ return true;
320
+ };
251
321
  const reportProgress = (data) => {
252
- const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
322
+ if (isStopped()) return;
323
+ const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : totalSize;
253
324
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
254
325
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
255
326
  onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
256
327
  };
257
328
  const safeError = (err) => {
258
329
  if (state === "closed" || state === "completed" || state === "cancelled") return;
259
- state = "closed";
330
+ transitionTo("closed");
260
331
  onError?.(err);
261
332
  cleanup();
262
333
  };
263
334
  const safeComplete = () => {
264
- if (state !== "finishing") return;
265
- state = "completed";
335
+ if (state !== "awaiting_ack" && state !== "finishing") return;
336
+ transitionTo("completed");
266
337
  onComplete?.();
267
338
  cleanup();
268
339
  };
@@ -271,6 +342,13 @@ async function startP2PSend(opts) {
271
342
  clearInterval(heartbeatTimer);
272
343
  heartbeatTimer = null;
273
344
  }
345
+ if (healthCheckTimer) {
346
+ clearInterval(healthCheckTimer);
347
+ healthCheckTimer = null;
348
+ }
349
+ ackResolvers.forEach((resolve) => resolve());
350
+ ackResolvers = [];
351
+ unackedChunks.clear();
274
352
  if (typeof window !== "undefined") {
275
353
  window.removeEventListener("beforeunload", handleUnload);
276
354
  }
@@ -295,8 +373,12 @@ async function startP2PSend(opts) {
295
373
  }
296
374
  const stop = () => {
297
375
  if (state === "closed" || state === "cancelled") return;
298
- const wasActive = state === "transferring" || state === "finishing";
299
- state = "cancelled";
376
+ if (state === "completed") {
377
+ cleanup();
378
+ return;
379
+ }
380
+ const wasActive = state === "transferring" || state === "finishing" || state === "awaiting_ack";
381
+ transitionTo("cancelled");
300
382
  try {
301
383
  if (activeConn && activeConn.open) {
302
384
  activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
@@ -309,8 +391,114 @@ async function startP2PSend(opts) {
309
391
  cleanup();
310
392
  };
311
393
  const isStopped = () => state === "closed" || state === "cancelled";
394
+ const startHealthMonitoring = (conn) => {
395
+ if (!onConnectionHealth) return;
396
+ healthCheckTimer = setInterval(() => {
397
+ if (isStopped()) return;
398
+ const dc = conn._dc;
399
+ if (!dc) return;
400
+ const health = {
401
+ iceConnectionState: dc.readyState === "open" ? "connected" : "disconnected",
402
+ bufferedAmount: dc.bufferedAmount,
403
+ lastActivityMs: Date.now() - lastActivityTime
404
+ };
405
+ onConnectionHealth(health);
406
+ }, 2e3);
407
+ };
408
+ const handleChunkAck = (msg) => {
409
+ lastActivityTime = Date.now();
410
+ unackedChunks.delete(msg.seq);
411
+ reportProgress({ received: msg.received, total: totalSize });
412
+ const resolver = ackResolvers.shift();
413
+ if (resolver) resolver();
414
+ };
415
+ const waitForAck = () => {
416
+ return new Promise((resolve) => {
417
+ ackResolvers.push(resolve);
418
+ });
419
+ };
420
+ const sendChunk = async (conn, data, offset, fileTotal) => {
421
+ if (chunkAcknowledgments) {
422
+ while (unackedChunks.size >= maxUnackedChunks) {
423
+ const now = Date.now();
424
+ for (const [_seq, chunk] of unackedChunks) {
425
+ if (now - chunk.sentAt > P2P_UNACKED_CHUNK_TIMEOUT_MS) {
426
+ throw new DropgateNetworkError("Receiver stopped acknowledging chunks");
427
+ }
428
+ }
429
+ await Promise.race([
430
+ waitForAck(),
431
+ sleep(1e3)
432
+ // Timeout to prevent deadlock
433
+ ]);
434
+ if (isStopped()) return;
435
+ }
436
+ }
437
+ const seq = nextSeq++;
438
+ if (chunkAcknowledgments) {
439
+ unackedChunks.set(seq, { offset, size: data.byteLength, sentAt: Date.now() });
440
+ }
441
+ conn.send({ t: "chunk", seq, offset, size: data.byteLength, total: fileTotal ?? totalSize });
442
+ conn.send(data);
443
+ sentBytes += data.byteLength;
444
+ const dc = conn._dc;
445
+ if (dc && bufferHighWaterMark > 0) {
446
+ while (dc.bufferedAmount > bufferHighWaterMark) {
447
+ await new Promise((resolve) => {
448
+ const fallback = setTimeout(resolve, 60);
449
+ try {
450
+ dc.addEventListener(
451
+ "bufferedamountlow",
452
+ () => {
453
+ clearTimeout(fallback);
454
+ resolve();
455
+ },
456
+ { once: true }
457
+ );
458
+ } catch {
459
+ }
460
+ });
461
+ if (isStopped()) return;
462
+ }
463
+ }
464
+ };
465
+ const waitForEndAck = async (conn, ackPromise) => {
466
+ const baseTimeout = endAckTimeoutMs;
467
+ for (let attempt = 0; attempt < P2P_END_ACK_RETRIES; attempt++) {
468
+ conn.send({ t: "end", attempt });
469
+ const timeout = baseTimeout * Math.pow(1.5, attempt);
470
+ const result = await Promise.race([
471
+ ackPromise,
472
+ sleep(timeout).then(() => null)
473
+ ]);
474
+ if (result && result.t === "end_ack") {
475
+ return result;
476
+ }
477
+ if (isStopped()) {
478
+ throw new DropgateNetworkError("Connection closed during completion.");
479
+ }
480
+ }
481
+ throw new DropgateNetworkError("Receiver did not confirm completion after retries.");
482
+ };
312
483
  peer.on("connection", (conn) => {
313
- if (state === "closed") return;
484
+ if (isStopped()) return;
485
+ const now = Date.now();
486
+ while (connectionAttempts.length > 0 && connectionAttempts[0] < now - CONNECTION_RATE_WINDOW_MS) {
487
+ connectionAttempts.shift();
488
+ }
489
+ if (connectionAttempts.length >= MAX_CONNECTION_ATTEMPTS) {
490
+ console.warn("[P2P Send] Connection rate limit exceeded, rejecting connection");
491
+ try {
492
+ conn.send({ t: "error", message: "Too many connection attempts. Please wait." });
493
+ } catch {
494
+ }
495
+ try {
496
+ conn.close();
497
+ } catch {
498
+ }
499
+ return;
500
+ }
501
+ connectionAttempts.push(now);
314
502
  if (activeConn) {
315
503
  const isOldConnOpen = activeConn.open !== false;
316
504
  if (isOldConnOpen && state === "transferring") {
@@ -329,8 +517,21 @@ async function startP2PSend(opts) {
329
517
  } catch {
330
518
  }
331
519
  activeConn = null;
520
+ if (transferEverStarted) {
521
+ try {
522
+ conn.send({ t: "error", message: "Transfer already started with another receiver. Cannot reconnect." });
523
+ } catch {
524
+ }
525
+ try {
526
+ conn.close();
527
+ } catch {
528
+ }
529
+ return;
530
+ }
332
531
  state = "listening";
333
532
  sentBytes = 0;
533
+ nextSeq = 0;
534
+ unackedChunks.clear();
334
535
  } else {
335
536
  try {
336
537
  conn.send({ t: "error", message: "Another receiver is already connected." });
@@ -344,60 +545,98 @@ async function startP2PSend(opts) {
344
545
  }
345
546
  }
346
547
  activeConn = conn;
347
- state = "negotiating";
348
- onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
548
+ transitionTo("handshaking");
549
+ if (!isStopped()) onStatus?.({ phase: "connected", message: "Receiver connected." });
550
+ lastActivityTime = Date.now();
551
+ let helloResolve = null;
349
552
  let readyResolve = null;
350
- let ackResolve = null;
553
+ let endAckResolve = null;
554
+ let fileEndAckResolve = null;
555
+ const helloPromise = new Promise((resolve) => {
556
+ helloResolve = resolve;
557
+ });
351
558
  const readyPromise = new Promise((resolve) => {
352
559
  readyResolve = resolve;
353
560
  });
354
- const ackPromise = new Promise((resolve) => {
355
- ackResolve = resolve;
561
+ const endAckPromise = new Promise((resolve) => {
562
+ endAckResolve = resolve;
356
563
  });
357
564
  conn.on("data", (data) => {
358
- if (!data || typeof data !== "object" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
565
+ lastActivityTime = Date.now();
566
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
359
567
  return;
360
568
  }
569
+ if (!isP2PMessage(data)) return;
361
570
  const msg = data;
362
- if (!msg.t) return;
363
- if (msg.t === "ready") {
364
- onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
365
- readyResolve?.();
366
- return;
367
- }
368
- if (msg.t === "progress") {
369
- reportProgress({ received: msg.received || 0, total: msg.total || 0 });
370
- return;
371
- }
372
- if (msg.t === "ack" && msg.phase === "end") {
373
- ackResolve?.(msg);
374
- return;
375
- }
376
- if (msg.t === "pong") {
377
- return;
378
- }
379
- if (msg.t === "error") {
380
- safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
381
- return;
382
- }
383
- if (msg.t === "cancelled") {
384
- if (state === "cancelled" || state === "closed" || state === "completed") return;
385
- state = "cancelled";
386
- onCancel?.({ cancelledBy: "receiver", message: msg.message });
387
- cleanup();
571
+ switch (msg.t) {
572
+ case "hello":
573
+ helloResolve?.(msg.protocolVersion);
574
+ break;
575
+ case "ready":
576
+ if (!isStopped()) onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
577
+ readyResolve?.();
578
+ break;
579
+ case "chunk_ack":
580
+ handleChunkAck(msg);
581
+ break;
582
+ case "file_end_ack":
583
+ fileEndAckResolve?.(msg);
584
+ break;
585
+ case "end_ack":
586
+ endAckResolve?.(msg);
587
+ break;
588
+ case "pong":
589
+ break;
590
+ case "error":
591
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
592
+ break;
593
+ case "cancelled":
594
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
595
+ transitionTo("cancelled");
596
+ onCancel?.({ cancelledBy: "receiver", message: msg.reason });
597
+ cleanup();
598
+ break;
388
599
  }
389
600
  });
390
601
  conn.on("open", async () => {
391
602
  try {
392
603
  if (isStopped()) return;
604
+ startHealthMonitoring(conn);
605
+ conn.send({
606
+ t: "hello",
607
+ protocolVersion: P2P_PROTOCOL_VERSION,
608
+ sessionId
609
+ });
610
+ const receiverVersion = await Promise.race([
611
+ helloPromise,
612
+ sleep(1e4).then(() => null)
613
+ ]);
614
+ if (isStopped()) return;
615
+ if (receiverVersion === null) {
616
+ throw new DropgateNetworkError("Receiver did not respond to handshake.");
617
+ } else if (receiverVersion !== P2P_PROTOCOL_VERSION) {
618
+ throw new DropgateNetworkError(
619
+ `Protocol version mismatch: sender v${P2P_PROTOCOL_VERSION}, receiver v${receiverVersion}`
620
+ );
621
+ }
622
+ transitionTo("negotiating");
623
+ if (!isStopped()) onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
624
+ if (isMultiFile) {
625
+ conn.send({
626
+ t: "file_list",
627
+ fileCount: files.length,
628
+ files: files.map((f) => ({ name: f.name, size: f.size, mime: f.type || "application/octet-stream" })),
629
+ totalSize
630
+ });
631
+ }
393
632
  conn.send({
394
633
  t: "meta",
395
634
  sessionId,
396
- name: file.name,
397
- size: file.size,
398
- mime: file.type || "application/octet-stream"
635
+ name: files[0].name,
636
+ size: files[0].size,
637
+ mime: files[0].type || "application/octet-stream",
638
+ ...isMultiFile ? { fileIndex: 0 } : {}
399
639
  });
400
- const total = file.size;
401
640
  const dc = conn._dc;
402
641
  if (dc && Number.isFinite(bufferLowWaterMark)) {
403
642
  try {
@@ -409,56 +648,61 @@ async function startP2PSend(opts) {
409
648
  if (isStopped()) return;
410
649
  if (heartbeatIntervalMs > 0) {
411
650
  heartbeatTimer = setInterval(() => {
412
- if (state === "transferring" || state === "finishing") {
651
+ if (state === "transferring" || state === "finishing" || state === "awaiting_ack") {
413
652
  try {
414
- conn.send({ t: "ping" });
653
+ conn.send({ t: "ping", timestamp: Date.now() });
415
654
  } catch {
416
655
  }
417
656
  }
418
657
  }, heartbeatIntervalMs);
419
658
  }
420
- state = "transferring";
421
- for (let offset = 0; offset < total; offset += chunkSize) {
422
- if (isStopped()) return;
423
- const slice = file.slice(offset, offset + chunkSize);
424
- const buf = await slice.arrayBuffer();
659
+ transitionTo("transferring");
660
+ transferEverStarted = true;
661
+ let overallSentBytes = 0;
662
+ for (let fi = 0; fi < files.length; fi++) {
663
+ const currentFile = files[fi];
664
+ if (isMultiFile && fi > 0) {
665
+ conn.send({
666
+ t: "meta",
667
+ sessionId,
668
+ name: currentFile.name,
669
+ size: currentFile.size,
670
+ mime: currentFile.type || "application/octet-stream",
671
+ fileIndex: fi
672
+ });
673
+ }
674
+ for (let offset = 0; offset < currentFile.size; offset += chunkSize) {
675
+ if (isStopped()) return;
676
+ const slice = currentFile.slice(offset, offset + chunkSize);
677
+ const buf = await slice.arrayBuffer();
678
+ if (isStopped()) return;
679
+ await sendChunk(conn, buf, offset, currentFile.size);
680
+ overallSentBytes += buf.byteLength;
681
+ reportProgress({ received: overallSentBytes, total: totalSize });
682
+ }
425
683
  if (isStopped()) return;
426
- conn.send(buf);
427
- sentBytes += buf.byteLength;
428
- if (dc) {
429
- while (dc.bufferedAmount > bufferHighWaterMark) {
430
- await new Promise((resolve) => {
431
- const fallback = setTimeout(resolve, 60);
432
- try {
433
- dc.addEventListener(
434
- "bufferedamountlow",
435
- () => {
436
- clearTimeout(fallback);
437
- resolve();
438
- },
439
- { once: true }
440
- );
441
- } catch {
442
- }
443
- });
684
+ if (isMultiFile) {
685
+ const fileEndAckPromise = new Promise((resolve) => {
686
+ fileEndAckResolve = resolve;
687
+ });
688
+ conn.send({ t: "file_end", fileIndex: fi });
689
+ const feAck = await Promise.race([
690
+ fileEndAckPromise,
691
+ sleep(endAckTimeoutMs).then(() => null)
692
+ ]);
693
+ if (isStopped()) return;
694
+ if (!feAck) {
695
+ throw new DropgateNetworkError(`Receiver did not confirm receipt of file ${fi + 1}/${files.length}.`);
444
696
  }
445
697
  }
446
698
  }
447
699
  if (isStopped()) return;
448
- state = "finishing";
449
- conn.send({ t: "end" });
450
- const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
451
- const ackResult = await Promise.race([
452
- ackPromise,
453
- sleep(ackTimeoutMs || 15e3).catch(() => null)
454
- ]);
700
+ transitionTo("finishing");
701
+ transitionTo("awaiting_ack");
702
+ const ackResult = await waitForEndAck(conn, endAckPromise);
455
703
  if (isStopped()) return;
456
- if (!ackResult || typeof ackResult !== "object") {
457
- throw new DropgateNetworkError("Receiver did not confirm completion.");
458
- }
459
- const ackData = ackResult;
460
- const ackTotal = Number(ackData.total) || file.size;
461
- const ackReceived = Number(ackData.received) || 0;
704
+ const ackTotal = Number(ackResult.total) || totalSize;
705
+ const ackReceived = Number(ackResult.received) || 0;
462
706
  if (ackTotal && ackReceived < ackTotal) {
463
707
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
464
708
  }
@@ -476,14 +720,24 @@ async function startP2PSend(opts) {
476
720
  cleanup();
477
721
  return;
478
722
  }
723
+ if (state === "awaiting_ack") {
724
+ setTimeout(() => {
725
+ if (state === "awaiting_ack") {
726
+ safeError(new DropgateNetworkError("Connection closed while awaiting confirmation."));
727
+ }
728
+ }, P2P_CLOSE_GRACE_PERIOD_MS);
729
+ return;
730
+ }
479
731
  if (state === "transferring" || state === "finishing") {
480
- state = "cancelled";
732
+ transitionTo("cancelled");
481
733
  onCancel?.({ cancelledBy: "receiver" });
482
734
  cleanup();
483
735
  } else {
484
736
  activeConn = null;
485
737
  state = "listening";
486
738
  sentBytes = 0;
739
+ nextSeq = 0;
740
+ unackedChunks.clear();
487
741
  onDisconnect?.();
488
742
  }
489
743
  });
@@ -503,6 +757,16 @@ async function startP2PSend(opts) {
503
757
  }
504
758
 
505
759
  // src/p2p/receive.ts
760
+ var ALLOWED_TRANSITIONS2 = {
761
+ initializing: ["connecting", "closed"],
762
+ connecting: ["handshaking", "closed", "cancelled"],
763
+ handshaking: ["negotiating", "closed", "cancelled"],
764
+ negotiating: ["transferring", "closed", "cancelled"],
765
+ transferring: ["completed", "closed", "cancelled"],
766
+ completed: ["closed"],
767
+ cancelled: ["closed"],
768
+ closed: []
769
+ };
506
770
  async function startP2PReceive(opts) {
507
771
  const {
508
772
  code,
@@ -519,6 +783,8 @@ async function startP2PReceive(opts) {
519
783
  onMeta,
520
784
  onData,
521
785
  onProgress,
786
+ onFileStart,
787
+ onFileEnd,
522
788
  onComplete,
523
789
  onError,
524
790
  onDisconnect,
@@ -556,11 +822,26 @@ async function startP2PReceive(opts) {
556
822
  let total = 0;
557
823
  let received = 0;
558
824
  let currentSessionId = null;
559
- let lastProgressSentAt = 0;
560
- const progressIntervalMs = 120;
561
825
  let writeQueue = Promise.resolve();
562
826
  let watchdogTimer = null;
563
827
  let activeConn = null;
828
+ let pendingChunk = null;
829
+ let fileList = null;
830
+ let currentFileReceived = 0;
831
+ let totalReceivedAllFiles = 0;
832
+ let expectedChunkSeq = 0;
833
+ let writeQueueDepth = 0;
834
+ const MAX_WRITE_QUEUE_DEPTH = 100;
835
+ const MAX_FILE_COUNT = 1e4;
836
+ const transitionTo = (newState) => {
837
+ if (!ALLOWED_TRANSITIONS2[state].includes(newState)) {
838
+ console.warn(`[P2P Receive] Invalid state transition: ${state} -> ${newState}`);
839
+ return false;
840
+ }
841
+ state = newState;
842
+ return true;
843
+ };
844
+ const isStopped = () => state === "closed" || state === "cancelled";
564
845
  const resetWatchdog = () => {
565
846
  if (watchdogTimeoutMs <= 0) return;
566
847
  if (watchdogTimer) {
@@ -580,15 +861,14 @@ async function startP2PReceive(opts) {
580
861
  };
581
862
  const safeError = (err) => {
582
863
  if (state === "closed" || state === "completed" || state === "cancelled") return;
583
- state = "closed";
864
+ transitionTo("closed");
584
865
  onError?.(err);
585
866
  cleanup();
586
867
  };
587
868
  const safeComplete = (completeData) => {
588
869
  if (state !== "transferring") return;
589
- state = "completed";
870
+ transitionTo("completed");
590
871
  onComplete?.(completeData);
591
- cleanup();
592
872
  };
593
873
  const cleanup = () => {
594
874
  clearWatchdog();
@@ -612,11 +892,15 @@ async function startP2PReceive(opts) {
612
892
  }
613
893
  const stop = () => {
614
894
  if (state === "closed" || state === "cancelled") return;
895
+ if (state === "completed") {
896
+ cleanup();
897
+ return;
898
+ }
615
899
  const wasActive = state === "transferring";
616
- state = "cancelled";
900
+ transitionTo("cancelled");
617
901
  try {
618
902
  if (activeConn && activeConn.open) {
619
- activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
903
+ activeConn.send({ t: "cancelled", reason: "Receiver cancelled the transfer." });
620
904
  }
621
905
  } catch {
622
906
  }
@@ -625,23 +909,122 @@ async function startP2PReceive(opts) {
625
909
  }
626
910
  cleanup();
627
911
  };
912
+ const sendChunkAck = (conn, seq) => {
913
+ try {
914
+ conn.send({ t: "chunk_ack", seq, received });
915
+ } catch {
916
+ }
917
+ };
628
918
  peer.on("error", (err) => {
629
919
  safeError(err);
630
920
  });
631
921
  peer.on("open", () => {
632
- state = "connecting";
922
+ transitionTo("connecting");
633
923
  const conn = peer.connect(normalizedCode, { reliable: true });
634
924
  activeConn = conn;
635
925
  conn.on("open", () => {
636
- state = "negotiating";
637
- onStatus?.({ phase: "connected", message: "Waiting for file details..." });
926
+ transitionTo("handshaking");
927
+ onStatus?.({ phase: "connected", message: "Connected." });
928
+ conn.send({
929
+ t: "hello",
930
+ protocolVersion: P2P_PROTOCOL_VERSION,
931
+ sessionId: ""
932
+ });
638
933
  });
639
934
  conn.on("data", async (data) => {
640
935
  try {
641
- resetWatchdog();
642
- if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
643
- const msg = data;
644
- if (msg.t === "meta") {
936
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || typeof Blob !== "undefined" && data instanceof Blob) {
937
+ if (state !== "transferring") {
938
+ throw new DropgateValidationError(
939
+ "Received binary data before transfer was accepted. Possible malicious sender."
940
+ );
941
+ }
942
+ resetWatchdog();
943
+ if (writeQueueDepth >= MAX_WRITE_QUEUE_DEPTH) {
944
+ throw new DropgateNetworkError("Write queue overflow - receiver cannot keep up");
945
+ }
946
+ let bufPromise;
947
+ if (data instanceof ArrayBuffer) {
948
+ bufPromise = Promise.resolve(new Uint8Array(data));
949
+ } else if (ArrayBuffer.isView(data)) {
950
+ bufPromise = Promise.resolve(
951
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
952
+ );
953
+ } else if (typeof Blob !== "undefined" && data instanceof Blob) {
954
+ bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
955
+ } else {
956
+ return;
957
+ }
958
+ const chunkSeq = pendingChunk?.seq ?? -1;
959
+ const expectedSize = pendingChunk?.size;
960
+ pendingChunk = null;
961
+ writeQueueDepth++;
962
+ writeQueue = writeQueue.then(async () => {
963
+ const buf = await bufPromise;
964
+ if (expectedSize !== void 0 && buf.byteLength !== expectedSize) {
965
+ throw new DropgateValidationError(
966
+ `Chunk size mismatch: expected ${expectedSize}, got ${buf.byteLength}`
967
+ );
968
+ }
969
+ const newReceived = received + buf.byteLength;
970
+ if (total > 0 && newReceived > total) {
971
+ throw new DropgateValidationError(
972
+ `Received more data than expected: ${newReceived} > ${total}`
973
+ );
974
+ }
975
+ if (onData) {
976
+ await onData(buf);
977
+ }
978
+ received += buf.byteLength;
979
+ currentFileReceived += buf.byteLength;
980
+ const progressReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
981
+ const progressTotal = fileList ? fileList.totalSize : total;
982
+ const percent = progressTotal ? Math.min(100, progressReceived / progressTotal * 100) : 0;
983
+ if (!isStopped()) onProgress?.({ processedBytes: progressReceived, totalBytes: progressTotal, percent });
984
+ if (chunkSeq >= 0) {
985
+ sendChunkAck(conn, chunkSeq);
986
+ }
987
+ }).catch((err) => {
988
+ try {
989
+ conn.send({
990
+ t: "error",
991
+ message: err?.message || "Receiver write failed."
992
+ });
993
+ } catch {
994
+ }
995
+ safeError(err);
996
+ }).finally(() => {
997
+ writeQueueDepth--;
998
+ });
999
+ return;
1000
+ }
1001
+ if (!isP2PMessage(data)) return;
1002
+ const msg = data;
1003
+ switch (msg.t) {
1004
+ case "hello":
1005
+ currentSessionId = msg.sessionId || null;
1006
+ transitionTo("negotiating");
1007
+ onStatus?.({ phase: "waiting", message: "Waiting for file details..." });
1008
+ break;
1009
+ case "file_list": {
1010
+ const fileListMsg = msg;
1011
+ if (fileListMsg.fileCount > MAX_FILE_COUNT) {
1012
+ throw new DropgateValidationError(`Too many files: ${fileListMsg.fileCount}`);
1013
+ }
1014
+ const sumSize = fileListMsg.files.reduce((sum, f) => sum + f.size, 0);
1015
+ if (sumSize !== fileListMsg.totalSize) {
1016
+ throw new DropgateValidationError(
1017
+ `File list size mismatch: declared ${fileListMsg.totalSize}, actual sum ${sumSize}`
1018
+ );
1019
+ }
1020
+ fileList = fileListMsg;
1021
+ total = fileListMsg.totalSize;
1022
+ break;
1023
+ }
1024
+ case "meta": {
1025
+ if (state !== "negotiating" && !(state === "transferring" && fileList)) {
1026
+ return;
1027
+ }
645
1028
  if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
646
1029
  try {
647
1030
  conn.send({ t: "error", message: "Busy with another session." });
@@ -653,40 +1036,96 @@ async function startP2PReceive(opts) {
653
1036
  currentSessionId = msg.sessionId;
654
1037
  }
655
1038
  const name = String(msg.name || "file");
656
- total = Number(msg.size) || 0;
1039
+ const fileSize = Number(msg.size) || 0;
1040
+ const fi = msg.fileIndex;
1041
+ if (fileList && typeof fi === "number" && fi > 0) {
1042
+ currentFileReceived = 0;
1043
+ onFileStart?.({ fileIndex: fi, name, size: fileSize });
1044
+ break;
1045
+ }
657
1046
  received = 0;
1047
+ currentFileReceived = 0;
1048
+ totalReceivedAllFiles = 0;
1049
+ if (!fileList) {
1050
+ total = fileSize;
1051
+ }
658
1052
  writeQueue = Promise.resolve();
659
1053
  const sendReady = () => {
660
- state = "transferring";
1054
+ transitionTo("transferring");
661
1055
  resetWatchdog();
1056
+ if (fileList) {
1057
+ onFileStart?.({ fileIndex: 0, name, size: fileSize });
1058
+ }
662
1059
  try {
663
1060
  conn.send({ t: "ready" });
664
1061
  } catch {
665
1062
  }
666
1063
  };
1064
+ const metaEvt = { name, total };
1065
+ if (fileList) {
1066
+ metaEvt.fileCount = fileList.fileCount;
1067
+ metaEvt.files = fileList.files.map((f) => ({ name: f.name, size: f.size }));
1068
+ metaEvt.totalSize = fileList.totalSize;
1069
+ }
667
1070
  if (autoReady) {
668
- onMeta?.({ name, total });
669
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1071
+ if (!isStopped()) {
1072
+ onMeta?.(metaEvt);
1073
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1074
+ }
670
1075
  sendReady();
671
1076
  } else {
672
- onMeta?.({ name, total, sendReady });
673
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1077
+ metaEvt.sendReady = sendReady;
1078
+ if (!isStopped()) {
1079
+ onMeta?.(metaEvt);
1080
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1081
+ }
674
1082
  }
675
- return;
1083
+ break;
1084
+ }
1085
+ case "chunk": {
1086
+ const chunkMsg = msg;
1087
+ if (state !== "transferring") {
1088
+ throw new DropgateValidationError(
1089
+ "Received chunk message before transfer was accepted."
1090
+ );
1091
+ }
1092
+ if (chunkMsg.seq !== expectedChunkSeq) {
1093
+ throw new DropgateValidationError(
1094
+ `Chunk sequence error: expected ${expectedChunkSeq}, got ${chunkMsg.seq}`
1095
+ );
1096
+ }
1097
+ expectedChunkSeq++;
1098
+ pendingChunk = chunkMsg;
1099
+ break;
676
1100
  }
677
- if (msg.t === "ping") {
1101
+ case "ping":
678
1102
  try {
679
- conn.send({ t: "pong" });
1103
+ conn.send({ t: "pong", timestamp: Date.now() });
680
1104
  } catch {
681
1105
  }
682
- return;
1106
+ break;
1107
+ case "file_end": {
1108
+ clearWatchdog();
1109
+ await writeQueue;
1110
+ const feIdx = msg.fileIndex;
1111
+ onFileEnd?.({ fileIndex: feIdx, receivedBytes: currentFileReceived });
1112
+ try {
1113
+ conn.send({ t: "file_end_ack", fileIndex: feIdx, received: currentFileReceived, size: currentFileReceived });
1114
+ } catch {
1115
+ }
1116
+ totalReceivedAllFiles += currentFileReceived;
1117
+ currentFileReceived = 0;
1118
+ resetWatchdog();
1119
+ break;
683
1120
  }
684
- if (msg.t === "end") {
1121
+ case "end":
685
1122
  clearWatchdog();
686
1123
  await writeQueue;
687
- if (total && received < total) {
1124
+ const finalReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
1125
+ const finalTotal = fileList ? fileList.totalSize : total;
1126
+ if (finalTotal && finalReceived < finalTotal) {
688
1127
  const err = new DropgateNetworkError(
689
- "Transfer ended before the full file was received."
1128
+ "Transfer ended before all data was received."
690
1129
  );
691
1130
  try {
692
1131
  conn.send({ t: "error", message: err.message });
@@ -695,62 +1134,31 @@ async function startP2PReceive(opts) {
695
1134
  throw err;
696
1135
  }
697
1136
  try {
698
- conn.send({ t: "ack", phase: "end", received, total });
1137
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
699
1138
  } catch {
700
1139
  }
701
- safeComplete({ received, total });
702
- return;
703
- }
704
- if (msg.t === "error") {
1140
+ safeComplete({ received: finalReceived, total: finalTotal });
1141
+ (async () => {
1142
+ for (let i = 0; i < 2; i++) {
1143
+ await sleep(P2P_END_ACK_RETRY_DELAY_MS);
1144
+ try {
1145
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
1146
+ } catch {
1147
+ break;
1148
+ }
1149
+ }
1150
+ })().catch(() => {
1151
+ });
1152
+ break;
1153
+ case "error":
705
1154
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
706
- }
707
- if (msg.t === "cancelled") {
1155
+ case "cancelled":
708
1156
  if (state === "cancelled" || state === "closed" || state === "completed") return;
709
- state = "cancelled";
710
- onCancel?.({ cancelledBy: "sender", message: msg.message });
1157
+ transitionTo("cancelled");
1158
+ onCancel?.({ cancelledBy: "sender", message: msg.reason });
711
1159
  cleanup();
712
- return;
713
- }
714
- return;
1160
+ break;
715
1161
  }
716
- let bufPromise;
717
- if (data instanceof ArrayBuffer) {
718
- bufPromise = Promise.resolve(new Uint8Array(data));
719
- } else if (ArrayBuffer.isView(data)) {
720
- bufPromise = Promise.resolve(
721
- new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
722
- );
723
- } else if (typeof Blob !== "undefined" && data instanceof Blob) {
724
- bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
725
- } else {
726
- return;
727
- }
728
- writeQueue = writeQueue.then(async () => {
729
- const buf = await bufPromise;
730
- if (onData) {
731
- await onData(buf);
732
- }
733
- received += buf.byteLength;
734
- const percent = total ? Math.min(100, received / total * 100) : 0;
735
- onProgress?.({ processedBytes: received, totalBytes: total, percent });
736
- const now = Date.now();
737
- if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
738
- lastProgressSentAt = now;
739
- try {
740
- conn.send({ t: "progress", received, total });
741
- } catch {
742
- }
743
- }
744
- }).catch((err) => {
745
- try {
746
- conn.send({
747
- t: "error",
748
- message: err?.message || "Receiver write failed."
749
- });
750
- } catch {
751
- }
752
- safeError(err);
753
- });
754
1162
  } catch (err) {
755
1163
  safeError(err);
756
1164
  }
@@ -761,11 +1169,11 @@ async function startP2PReceive(opts) {
761
1169
  return;
762
1170
  }
763
1171
  if (state === "transferring") {
764
- state = "cancelled";
1172
+ transitionTo("cancelled");
765
1173
  onCancel?.({ cancelledBy: "sender" });
766
1174
  cleanup();
767
1175
  } else if (state === "negotiating") {
768
- state = "closed";
1176
+ transitionTo("closed");
769
1177
  cleanup();
770
1178
  onDisconnect?.();
771
1179
  } else {
@@ -782,16 +1190,4 @@ async function startP2PReceive(opts) {
782
1190
  getSessionId: () => currentSessionId
783
1191
  };
784
1192
  }
785
- // Annotate the CommonJS export names for ESM import in node:
786
- 0 && (module.exports = {
787
- buildPeerOptions,
788
- createPeerWithRetries,
789
- generateP2PCode,
790
- isLocalhostHostname,
791
- isP2PCodeLike,
792
- isSecureContextForP2P,
793
- resolvePeerConfig,
794
- startP2PReceive,
795
- startP2PSend
796
- });
797
1193
  //# sourceMappingURL=index.cjs.map