@dropgate/core 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,56 @@ 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
182
219
  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)}`;
220
+ return crypto.randomUUID();
187
221
  }
222
+ var ALLOWED_TRANSITIONS = {
223
+ initializing: ["listening", "closed"],
224
+ listening: ["handshaking", "closed", "cancelled"],
225
+ handshaking: ["negotiating", "closed", "cancelled"],
226
+ negotiating: ["transferring", "closed", "cancelled"],
227
+ transferring: ["finishing", "closed", "cancelled"],
228
+ finishing: ["awaiting_ack", "closed", "cancelled"],
229
+ awaiting_ack: ["completed", "closed", "cancelled"],
230
+ completed: ["closed"],
231
+ cancelled: ["closed"],
232
+ closed: []
233
+ };
188
234
  async function startP2PSend(opts) {
189
235
  const {
190
236
  file,
@@ -198,21 +244,27 @@ async function startP2PSend(opts) {
198
244
  codeGenerator,
199
245
  cryptoObj,
200
246
  maxAttempts = 4,
201
- chunkSize = 256 * 1024,
202
- endAckTimeoutMs = 15e3,
247
+ chunkSize = P2P_CHUNK_SIZE,
248
+ endAckTimeoutMs = P2P_END_ACK_TIMEOUT_MS,
203
249
  bufferHighWaterMark = 8 * 1024 * 1024,
204
250
  bufferLowWaterMark = 2 * 1024 * 1024,
205
251
  heartbeatIntervalMs = 5e3,
252
+ chunkAcknowledgments = true,
253
+ maxUnackedChunks = P2P_MAX_UNACKED_CHUNKS,
206
254
  onCode,
207
255
  onStatus,
208
256
  onProgress,
209
257
  onComplete,
210
258
  onError,
211
259
  onDisconnect,
212
- onCancel
260
+ onCancel,
261
+ onConnectionHealth
213
262
  } = opts;
214
- if (!file) {
215
- throw new DropgateValidationError("File is missing.");
263
+ const files = Array.isArray(file) ? file : [file];
264
+ const isMultiFile = files.length > 1;
265
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
266
+ if (!files.length) {
267
+ throw new DropgateValidationError("At least one file is required.");
216
268
  }
217
269
  if (!Peer) {
218
270
  throw new DropgateValidationError(
@@ -248,21 +300,35 @@ async function startP2PSend(opts) {
248
300
  let activeConn = null;
249
301
  let sentBytes = 0;
250
302
  let heartbeatTimer = null;
303
+ let healthCheckTimer = null;
304
+ let lastActivityTime = Date.now();
305
+ const unackedChunks = /* @__PURE__ */ new Map();
306
+ let nextSeq = 0;
307
+ let ackResolvers = [];
308
+ const transitionTo = (newState) => {
309
+ if (!ALLOWED_TRANSITIONS[state].includes(newState)) {
310
+ console.warn(`[P2P Send] Invalid state transition: ${state} -> ${newState}`);
311
+ return false;
312
+ }
313
+ state = newState;
314
+ return true;
315
+ };
251
316
  const reportProgress = (data) => {
252
- const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
317
+ if (isStopped()) return;
318
+ const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : totalSize;
253
319
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
254
320
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
255
321
  onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
256
322
  };
257
323
  const safeError = (err) => {
258
324
  if (state === "closed" || state === "completed" || state === "cancelled") return;
259
- state = "closed";
325
+ transitionTo("closed");
260
326
  onError?.(err);
261
327
  cleanup();
262
328
  };
263
329
  const safeComplete = () => {
264
- if (state !== "finishing") return;
265
- state = "completed";
330
+ if (state !== "awaiting_ack" && state !== "finishing") return;
331
+ transitionTo("completed");
266
332
  onComplete?.();
267
333
  cleanup();
268
334
  };
@@ -271,6 +337,13 @@ async function startP2PSend(opts) {
271
337
  clearInterval(heartbeatTimer);
272
338
  heartbeatTimer = null;
273
339
  }
340
+ if (healthCheckTimer) {
341
+ clearInterval(healthCheckTimer);
342
+ healthCheckTimer = null;
343
+ }
344
+ ackResolvers.forEach((resolve) => resolve());
345
+ ackResolvers = [];
346
+ unackedChunks.clear();
274
347
  if (typeof window !== "undefined") {
275
348
  window.removeEventListener("beforeunload", handleUnload);
276
349
  }
@@ -295,8 +368,12 @@ async function startP2PSend(opts) {
295
368
  }
296
369
  const stop = () => {
297
370
  if (state === "closed" || state === "cancelled") return;
298
- const wasActive = state === "transferring" || state === "finishing";
299
- state = "cancelled";
371
+ if (state === "completed") {
372
+ cleanup();
373
+ return;
374
+ }
375
+ const wasActive = state === "transferring" || state === "finishing" || state === "awaiting_ack";
376
+ transitionTo("cancelled");
300
377
  try {
301
378
  if (activeConn && activeConn.open) {
302
379
  activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
@@ -309,8 +386,91 @@ async function startP2PSend(opts) {
309
386
  cleanup();
310
387
  };
311
388
  const isStopped = () => state === "closed" || state === "cancelled";
389
+ const startHealthMonitoring = (conn) => {
390
+ if (!onConnectionHealth) return;
391
+ healthCheckTimer = setInterval(() => {
392
+ if (isStopped()) return;
393
+ const dc = conn._dc;
394
+ if (!dc) return;
395
+ const health = {
396
+ iceConnectionState: dc.readyState === "open" ? "connected" : "disconnected",
397
+ bufferedAmount: dc.bufferedAmount,
398
+ lastActivityMs: Date.now() - lastActivityTime
399
+ };
400
+ onConnectionHealth(health);
401
+ }, 2e3);
402
+ };
403
+ const handleChunkAck = (msg) => {
404
+ lastActivityTime = Date.now();
405
+ unackedChunks.delete(msg.seq);
406
+ reportProgress({ received: msg.received, total: totalSize });
407
+ const resolver = ackResolvers.shift();
408
+ if (resolver) resolver();
409
+ };
410
+ const waitForAck = () => {
411
+ return new Promise((resolve) => {
412
+ ackResolvers.push(resolve);
413
+ });
414
+ };
415
+ const sendChunk = async (conn, data, offset, fileTotal) => {
416
+ if (chunkAcknowledgments) {
417
+ while (unackedChunks.size >= maxUnackedChunks) {
418
+ await Promise.race([
419
+ waitForAck(),
420
+ sleep(1e3)
421
+ // Timeout to prevent deadlock
422
+ ]);
423
+ if (isStopped()) return;
424
+ }
425
+ }
426
+ const seq = nextSeq++;
427
+ if (chunkAcknowledgments) {
428
+ unackedChunks.set(seq, { offset, size: data.byteLength, sentAt: Date.now() });
429
+ }
430
+ conn.send({ t: "chunk", seq, offset, size: data.byteLength, total: fileTotal ?? totalSize });
431
+ conn.send(data);
432
+ sentBytes += data.byteLength;
433
+ const dc = conn._dc;
434
+ if (dc && bufferHighWaterMark > 0) {
435
+ while (dc.bufferedAmount > bufferHighWaterMark) {
436
+ await new Promise((resolve) => {
437
+ const fallback = setTimeout(resolve, 60);
438
+ try {
439
+ dc.addEventListener(
440
+ "bufferedamountlow",
441
+ () => {
442
+ clearTimeout(fallback);
443
+ resolve();
444
+ },
445
+ { once: true }
446
+ );
447
+ } catch {
448
+ }
449
+ });
450
+ if (isStopped()) return;
451
+ }
452
+ }
453
+ };
454
+ const waitForEndAck = async (conn, ackPromise) => {
455
+ const baseTimeout = endAckTimeoutMs;
456
+ for (let attempt = 0; attempt < P2P_END_ACK_RETRIES; attempt++) {
457
+ conn.send({ t: "end", attempt });
458
+ const timeout = baseTimeout * Math.pow(1.5, attempt);
459
+ const result = await Promise.race([
460
+ ackPromise,
461
+ sleep(timeout).then(() => null)
462
+ ]);
463
+ if (result && result.t === "end_ack") {
464
+ return result;
465
+ }
466
+ if (isStopped()) {
467
+ throw new DropgateNetworkError("Connection closed during completion.");
468
+ }
469
+ }
470
+ throw new DropgateNetworkError("Receiver did not confirm completion after retries.");
471
+ };
312
472
  peer.on("connection", (conn) => {
313
- if (state === "closed") return;
473
+ if (isStopped()) return;
314
474
  if (activeConn) {
315
475
  const isOldConnOpen = activeConn.open !== false;
316
476
  if (isOldConnOpen && state === "transferring") {
@@ -331,6 +491,8 @@ async function startP2PSend(opts) {
331
491
  activeConn = null;
332
492
  state = "listening";
333
493
  sentBytes = 0;
494
+ nextSeq = 0;
495
+ unackedChunks.clear();
334
496
  } else {
335
497
  try {
336
498
  conn.send({ t: "error", message: "Another receiver is already connected." });
@@ -344,60 +506,98 @@ async function startP2PSend(opts) {
344
506
  }
345
507
  }
346
508
  activeConn = conn;
347
- state = "negotiating";
348
- onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
509
+ transitionTo("handshaking");
510
+ if (!isStopped()) onStatus?.({ phase: "connected", message: "Receiver connected." });
511
+ lastActivityTime = Date.now();
512
+ let helloResolve = null;
349
513
  let readyResolve = null;
350
- let ackResolve = null;
514
+ let endAckResolve = null;
515
+ let fileEndAckResolve = null;
516
+ const helloPromise = new Promise((resolve) => {
517
+ helloResolve = resolve;
518
+ });
351
519
  const readyPromise = new Promise((resolve) => {
352
520
  readyResolve = resolve;
353
521
  });
354
- const ackPromise = new Promise((resolve) => {
355
- ackResolve = resolve;
522
+ const endAckPromise = new Promise((resolve) => {
523
+ endAckResolve = resolve;
356
524
  });
357
525
  conn.on("data", (data) => {
358
- if (!data || typeof data !== "object" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
526
+ lastActivityTime = Date.now();
527
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
359
528
  return;
360
529
  }
530
+ if (!isP2PMessage(data)) return;
361
531
  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();
532
+ switch (msg.t) {
533
+ case "hello":
534
+ helloResolve?.(msg.protocolVersion);
535
+ break;
536
+ case "ready":
537
+ if (!isStopped()) onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
538
+ readyResolve?.();
539
+ break;
540
+ case "chunk_ack":
541
+ handleChunkAck(msg);
542
+ break;
543
+ case "file_end_ack":
544
+ fileEndAckResolve?.(msg);
545
+ break;
546
+ case "end_ack":
547
+ endAckResolve?.(msg);
548
+ break;
549
+ case "pong":
550
+ break;
551
+ case "error":
552
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
553
+ break;
554
+ case "cancelled":
555
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
556
+ transitionTo("cancelled");
557
+ onCancel?.({ cancelledBy: "receiver", message: msg.reason });
558
+ cleanup();
559
+ break;
388
560
  }
389
561
  });
390
562
  conn.on("open", async () => {
391
563
  try {
392
564
  if (isStopped()) return;
565
+ startHealthMonitoring(conn);
566
+ conn.send({
567
+ t: "hello",
568
+ protocolVersion: P2P_PROTOCOL_VERSION,
569
+ sessionId
570
+ });
571
+ const receiverVersion = await Promise.race([
572
+ helloPromise,
573
+ sleep(1e4).then(() => null)
574
+ ]);
575
+ if (isStopped()) return;
576
+ if (receiverVersion === null) {
577
+ throw new DropgateNetworkError("Receiver did not respond to handshake.");
578
+ } else if (receiverVersion !== P2P_PROTOCOL_VERSION) {
579
+ throw new DropgateNetworkError(
580
+ `Protocol version mismatch: sender v${P2P_PROTOCOL_VERSION}, receiver v${receiverVersion}`
581
+ );
582
+ }
583
+ transitionTo("negotiating");
584
+ if (!isStopped()) onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
585
+ if (isMultiFile) {
586
+ conn.send({
587
+ t: "file_list",
588
+ fileCount: files.length,
589
+ files: files.map((f) => ({ name: f.name, size: f.size, mime: f.type || "application/octet-stream" })),
590
+ totalSize
591
+ });
592
+ }
393
593
  conn.send({
394
594
  t: "meta",
395
595
  sessionId,
396
- name: file.name,
397
- size: file.size,
398
- mime: file.type || "application/octet-stream"
596
+ name: files[0].name,
597
+ size: files[0].size,
598
+ mime: files[0].type || "application/octet-stream",
599
+ ...isMultiFile ? { fileIndex: 0 } : {}
399
600
  });
400
- const total = file.size;
401
601
  const dc = conn._dc;
402
602
  if (dc && Number.isFinite(bufferLowWaterMark)) {
403
603
  try {
@@ -409,56 +609,60 @@ async function startP2PSend(opts) {
409
609
  if (isStopped()) return;
410
610
  if (heartbeatIntervalMs > 0) {
411
611
  heartbeatTimer = setInterval(() => {
412
- if (state === "transferring" || state === "finishing") {
612
+ if (state === "transferring" || state === "finishing" || state === "awaiting_ack") {
413
613
  try {
414
- conn.send({ t: "ping" });
614
+ conn.send({ t: "ping", timestamp: Date.now() });
415
615
  } catch {
416
616
  }
417
617
  }
418
618
  }, heartbeatIntervalMs);
419
619
  }
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();
620
+ transitionTo("transferring");
621
+ let overallSentBytes = 0;
622
+ for (let fi = 0; fi < files.length; fi++) {
623
+ const currentFile = files[fi];
624
+ if (isMultiFile && fi > 0) {
625
+ conn.send({
626
+ t: "meta",
627
+ sessionId,
628
+ name: currentFile.name,
629
+ size: currentFile.size,
630
+ mime: currentFile.type || "application/octet-stream",
631
+ fileIndex: fi
632
+ });
633
+ }
634
+ for (let offset = 0; offset < currentFile.size; offset += chunkSize) {
635
+ if (isStopped()) return;
636
+ const slice = currentFile.slice(offset, offset + chunkSize);
637
+ const buf = await slice.arrayBuffer();
638
+ if (isStopped()) return;
639
+ await sendChunk(conn, buf, offset, currentFile.size);
640
+ overallSentBytes += buf.byteLength;
641
+ reportProgress({ received: overallSentBytes, total: totalSize });
642
+ }
425
643
  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
- });
644
+ if (isMultiFile) {
645
+ const fileEndAckPromise = new Promise((resolve) => {
646
+ fileEndAckResolve = resolve;
647
+ });
648
+ conn.send({ t: "file_end", fileIndex: fi });
649
+ const feAck = await Promise.race([
650
+ fileEndAckPromise,
651
+ sleep(endAckTimeoutMs).then(() => null)
652
+ ]);
653
+ if (isStopped()) return;
654
+ if (!feAck) {
655
+ throw new DropgateNetworkError(`Receiver did not confirm receipt of file ${fi + 1}/${files.length}.`);
444
656
  }
445
657
  }
446
658
  }
447
659
  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
- ]);
660
+ transitionTo("finishing");
661
+ transitionTo("awaiting_ack");
662
+ const ackResult = await waitForEndAck(conn, endAckPromise);
455
663
  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;
664
+ const ackTotal = Number(ackResult.total) || totalSize;
665
+ const ackReceived = Number(ackResult.received) || 0;
462
666
  if (ackTotal && ackReceived < ackTotal) {
463
667
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
464
668
  }
@@ -476,14 +680,24 @@ async function startP2PSend(opts) {
476
680
  cleanup();
477
681
  return;
478
682
  }
683
+ if (state === "awaiting_ack") {
684
+ setTimeout(() => {
685
+ if (state === "awaiting_ack") {
686
+ safeError(new DropgateNetworkError("Connection closed while awaiting confirmation."));
687
+ }
688
+ }, P2P_CLOSE_GRACE_PERIOD_MS);
689
+ return;
690
+ }
479
691
  if (state === "transferring" || state === "finishing") {
480
- state = "cancelled";
692
+ transitionTo("cancelled");
481
693
  onCancel?.({ cancelledBy: "receiver" });
482
694
  cleanup();
483
695
  } else {
484
696
  activeConn = null;
485
697
  state = "listening";
486
698
  sentBytes = 0;
699
+ nextSeq = 0;
700
+ unackedChunks.clear();
487
701
  onDisconnect?.();
488
702
  }
489
703
  });
@@ -503,6 +717,16 @@ async function startP2PSend(opts) {
503
717
  }
504
718
 
505
719
  // src/p2p/receive.ts
720
+ var ALLOWED_TRANSITIONS2 = {
721
+ initializing: ["connecting", "closed"],
722
+ connecting: ["handshaking", "closed", "cancelled"],
723
+ handshaking: ["negotiating", "closed", "cancelled"],
724
+ negotiating: ["transferring", "closed", "cancelled"],
725
+ transferring: ["completed", "closed", "cancelled"],
726
+ completed: ["closed"],
727
+ cancelled: ["closed"],
728
+ closed: []
729
+ };
506
730
  async function startP2PReceive(opts) {
507
731
  const {
508
732
  code,
@@ -519,6 +743,8 @@ async function startP2PReceive(opts) {
519
743
  onMeta,
520
744
  onData,
521
745
  onProgress,
746
+ onFileStart,
747
+ onFileEnd,
522
748
  onComplete,
523
749
  onError,
524
750
  onDisconnect,
@@ -556,11 +782,22 @@ async function startP2PReceive(opts) {
556
782
  let total = 0;
557
783
  let received = 0;
558
784
  let currentSessionId = null;
559
- let lastProgressSentAt = 0;
560
- const progressIntervalMs = 120;
561
785
  let writeQueue = Promise.resolve();
562
786
  let watchdogTimer = null;
563
787
  let activeConn = null;
788
+ let pendingChunk = null;
789
+ let fileList = null;
790
+ let currentFileReceived = 0;
791
+ let totalReceivedAllFiles = 0;
792
+ const transitionTo = (newState) => {
793
+ if (!ALLOWED_TRANSITIONS2[state].includes(newState)) {
794
+ console.warn(`[P2P Receive] Invalid state transition: ${state} -> ${newState}`);
795
+ return false;
796
+ }
797
+ state = newState;
798
+ return true;
799
+ };
800
+ const isStopped = () => state === "closed" || state === "cancelled";
564
801
  const resetWatchdog = () => {
565
802
  if (watchdogTimeoutMs <= 0) return;
566
803
  if (watchdogTimer) {
@@ -580,15 +817,14 @@ async function startP2PReceive(opts) {
580
817
  };
581
818
  const safeError = (err) => {
582
819
  if (state === "closed" || state === "completed" || state === "cancelled") return;
583
- state = "closed";
820
+ transitionTo("closed");
584
821
  onError?.(err);
585
822
  cleanup();
586
823
  };
587
824
  const safeComplete = (completeData) => {
588
825
  if (state !== "transferring") return;
589
- state = "completed";
826
+ transitionTo("completed");
590
827
  onComplete?.(completeData);
591
- cleanup();
592
828
  };
593
829
  const cleanup = () => {
594
830
  clearWatchdog();
@@ -612,11 +848,15 @@ async function startP2PReceive(opts) {
612
848
  }
613
849
  const stop = () => {
614
850
  if (state === "closed" || state === "cancelled") return;
851
+ if (state === "completed") {
852
+ cleanup();
853
+ return;
854
+ }
615
855
  const wasActive = state === "transferring";
616
- state = "cancelled";
856
+ transitionTo("cancelled");
617
857
  try {
618
858
  if (activeConn && activeConn.open) {
619
- activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
859
+ activeConn.send({ t: "cancelled", reason: "Receiver cancelled the transfer." });
620
860
  }
621
861
  } catch {
622
862
  }
@@ -625,23 +865,88 @@ async function startP2PReceive(opts) {
625
865
  }
626
866
  cleanup();
627
867
  };
868
+ const sendChunkAck = (conn, seq) => {
869
+ try {
870
+ conn.send({ t: "chunk_ack", seq, received });
871
+ } catch {
872
+ }
873
+ };
628
874
  peer.on("error", (err) => {
629
875
  safeError(err);
630
876
  });
631
877
  peer.on("open", () => {
632
- state = "connecting";
878
+ transitionTo("connecting");
633
879
  const conn = peer.connect(normalizedCode, { reliable: true });
634
880
  activeConn = conn;
635
881
  conn.on("open", () => {
636
- state = "negotiating";
637
- onStatus?.({ phase: "connected", message: "Waiting for file details..." });
882
+ transitionTo("handshaking");
883
+ onStatus?.({ phase: "connected", message: "Connected." });
884
+ conn.send({
885
+ t: "hello",
886
+ protocolVersion: P2P_PROTOCOL_VERSION,
887
+ sessionId: ""
888
+ });
638
889
  });
639
890
  conn.on("data", async (data) => {
640
891
  try {
641
892
  resetWatchdog();
642
- if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
643
- const msg = data;
644
- if (msg.t === "meta") {
893
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || typeof Blob !== "undefined" && data instanceof Blob) {
894
+ let bufPromise;
895
+ if (data instanceof ArrayBuffer) {
896
+ bufPromise = Promise.resolve(new Uint8Array(data));
897
+ } else if (ArrayBuffer.isView(data)) {
898
+ bufPromise = Promise.resolve(
899
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
900
+ );
901
+ } else if (typeof Blob !== "undefined" && data instanceof Blob) {
902
+ bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
903
+ } else {
904
+ return;
905
+ }
906
+ const chunkSeq = pendingChunk?.seq ?? -1;
907
+ pendingChunk = null;
908
+ writeQueue = writeQueue.then(async () => {
909
+ const buf = await bufPromise;
910
+ if (onData) {
911
+ await onData(buf);
912
+ }
913
+ received += buf.byteLength;
914
+ currentFileReceived += buf.byteLength;
915
+ const progressReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
916
+ const progressTotal = fileList ? fileList.totalSize : total;
917
+ const percent = progressTotal ? Math.min(100, progressReceived / progressTotal * 100) : 0;
918
+ if (!isStopped()) onProgress?.({ processedBytes: progressReceived, totalBytes: progressTotal, percent });
919
+ if (chunkSeq >= 0) {
920
+ sendChunkAck(conn, chunkSeq);
921
+ }
922
+ }).catch((err) => {
923
+ try {
924
+ conn.send({
925
+ t: "error",
926
+ message: err?.message || "Receiver write failed."
927
+ });
928
+ } catch {
929
+ }
930
+ safeError(err);
931
+ });
932
+ return;
933
+ }
934
+ if (!isP2PMessage(data)) return;
935
+ const msg = data;
936
+ switch (msg.t) {
937
+ case "hello":
938
+ currentSessionId = msg.sessionId || null;
939
+ transitionTo("negotiating");
940
+ onStatus?.({ phase: "waiting", message: "Waiting for file details..." });
941
+ break;
942
+ case "file_list":
943
+ fileList = msg;
944
+ total = fileList.totalSize;
945
+ break;
946
+ case "meta": {
947
+ if (state !== "negotiating" && !(state === "transferring" && fileList)) {
948
+ return;
949
+ }
645
950
  if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
646
951
  try {
647
952
  conn.send({ t: "error", message: "Busy with another session." });
@@ -653,40 +958,83 @@ async function startP2PReceive(opts) {
653
958
  currentSessionId = msg.sessionId;
654
959
  }
655
960
  const name = String(msg.name || "file");
656
- total = Number(msg.size) || 0;
961
+ const fileSize = Number(msg.size) || 0;
962
+ const fi = msg.fileIndex;
963
+ if (fileList && typeof fi === "number" && fi > 0) {
964
+ currentFileReceived = 0;
965
+ onFileStart?.({ fileIndex: fi, name, size: fileSize });
966
+ break;
967
+ }
657
968
  received = 0;
969
+ currentFileReceived = 0;
970
+ totalReceivedAllFiles = 0;
971
+ if (!fileList) {
972
+ total = fileSize;
973
+ }
658
974
  writeQueue = Promise.resolve();
659
975
  const sendReady = () => {
660
- state = "transferring";
976
+ transitionTo("transferring");
661
977
  resetWatchdog();
978
+ if (fileList) {
979
+ onFileStart?.({ fileIndex: 0, name, size: fileSize });
980
+ }
662
981
  try {
663
982
  conn.send({ t: "ready" });
664
983
  } catch {
665
984
  }
666
985
  };
986
+ const metaEvt = { name, total };
987
+ if (fileList) {
988
+ metaEvt.fileCount = fileList.fileCount;
989
+ metaEvt.files = fileList.files.map((f) => ({ name: f.name, size: f.size }));
990
+ metaEvt.totalSize = fileList.totalSize;
991
+ }
667
992
  if (autoReady) {
668
- onMeta?.({ name, total });
669
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
993
+ if (!isStopped()) {
994
+ onMeta?.(metaEvt);
995
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
996
+ }
670
997
  sendReady();
671
998
  } else {
672
- onMeta?.({ name, total, sendReady });
673
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
999
+ metaEvt.sendReady = sendReady;
1000
+ if (!isStopped()) {
1001
+ onMeta?.(metaEvt);
1002
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
1003
+ }
674
1004
  }
675
- return;
1005
+ break;
676
1006
  }
677
- if (msg.t === "ping") {
1007
+ case "chunk":
1008
+ pendingChunk = msg;
1009
+ break;
1010
+ case "ping":
678
1011
  try {
679
- conn.send({ t: "pong" });
1012
+ conn.send({ t: "pong", timestamp: Date.now() });
680
1013
  } catch {
681
1014
  }
682
- return;
1015
+ break;
1016
+ case "file_end": {
1017
+ clearWatchdog();
1018
+ await writeQueue;
1019
+ const feIdx = msg.fileIndex;
1020
+ onFileEnd?.({ fileIndex: feIdx, receivedBytes: currentFileReceived });
1021
+ try {
1022
+ conn.send({ t: "file_end_ack", fileIndex: feIdx, received: currentFileReceived, size: currentFileReceived });
1023
+ } catch {
1024
+ }
1025
+ totalReceivedAllFiles += currentFileReceived;
1026
+ currentFileReceived = 0;
1027
+ resetWatchdog();
1028
+ break;
683
1029
  }
684
- if (msg.t === "end") {
1030
+ case "end":
685
1031
  clearWatchdog();
686
1032
  await writeQueue;
687
- if (total && received < total) {
1033
+ const finalReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
1034
+ const finalTotal = fileList ? fileList.totalSize : total;
1035
+ if (finalTotal && finalReceived < finalTotal) {
688
1036
  const err = new DropgateNetworkError(
689
- "Transfer ended before the full file was received."
1037
+ "Transfer ended before all data was received."
690
1038
  );
691
1039
  try {
692
1040
  conn.send({ t: "error", message: err.message });
@@ -695,62 +1043,31 @@ async function startP2PReceive(opts) {
695
1043
  throw err;
696
1044
  }
697
1045
  try {
698
- conn.send({ t: "ack", phase: "end", received, total });
1046
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
699
1047
  } catch {
700
1048
  }
701
- safeComplete({ received, total });
702
- return;
703
- }
704
- if (msg.t === "error") {
1049
+ safeComplete({ received: finalReceived, total: finalTotal });
1050
+ (async () => {
1051
+ for (let i = 0; i < 2; i++) {
1052
+ await sleep(P2P_END_ACK_RETRY_DELAY_MS);
1053
+ try {
1054
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
1055
+ } catch {
1056
+ break;
1057
+ }
1058
+ }
1059
+ })().catch(() => {
1060
+ });
1061
+ break;
1062
+ case "error":
705
1063
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
706
- }
707
- if (msg.t === "cancelled") {
1064
+ case "cancelled":
708
1065
  if (state === "cancelled" || state === "closed" || state === "completed") return;
709
- state = "cancelled";
710
- onCancel?.({ cancelledBy: "sender", message: msg.message });
1066
+ transitionTo("cancelled");
1067
+ onCancel?.({ cancelledBy: "sender", message: msg.reason });
711
1068
  cleanup();
712
- return;
713
- }
714
- return;
715
- }
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;
1069
+ break;
727
1070
  }
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
1071
  } catch (err) {
755
1072
  safeError(err);
756
1073
  }
@@ -761,11 +1078,11 @@ async function startP2PReceive(opts) {
761
1078
  return;
762
1079
  }
763
1080
  if (state === "transferring") {
764
- state = "cancelled";
1081
+ transitionTo("cancelled");
765
1082
  onCancel?.({ cancelledBy: "sender" });
766
1083
  cleanup();
767
1084
  } else if (state === "negotiating") {
768
- state = "closed";
1085
+ transitionTo("closed");
769
1086
  cleanup();
770
1087
  onDisconnect?.();
771
1088
  } else {
@@ -782,16 +1099,4 @@ async function startP2PReceive(opts) {
782
1099
  getSessionId: () => currentSessionId
783
1100
  };
784
1101
  }
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
1102
  //# sourceMappingURL=index.cjs.map