@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.
package/dist/p2p/index.js CHANGED
@@ -1,18 +1,16 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
1
5
  // src/errors.ts
2
6
  var DropgateError = class extends Error {
3
7
  constructor(message, opts = {}) {
4
- super(message);
8
+ super(message, opts.cause !== void 0 ? { cause: opts.cause } : void 0);
9
+ __publicField(this, "code");
10
+ __publicField(this, "details");
5
11
  this.name = this.constructor.name;
6
12
  this.code = opts.code || "DROPGATE_ERROR";
7
13
  this.details = opts.details;
8
- if (opts.cause !== void 0) {
9
- Object.defineProperty(this, "cause", {
10
- value: opts.cause,
11
- writable: false,
12
- enumerable: false,
13
- configurable: true
14
- });
15
- }
16
14
  }
17
15
  };
18
16
  var DropgateValidationError = class extends DropgateError {
@@ -144,13 +142,56 @@ async function createPeerWithRetries(opts) {
144
142
  throw lastError || new DropgateNetworkError("Could not establish PeerJS connection.");
145
143
  }
146
144
 
145
+ // src/p2p/protocol.ts
146
+ var P2P_PROTOCOL_VERSION = 3;
147
+ function isP2PMessage(value) {
148
+ if (!value || typeof value !== "object") return false;
149
+ const msg = value;
150
+ return typeof msg.t === "string" && [
151
+ "hello",
152
+ "file_list",
153
+ "meta",
154
+ "ready",
155
+ "chunk",
156
+ "chunk_ack",
157
+ "file_end",
158
+ "file_end_ack",
159
+ "end",
160
+ "end_ack",
161
+ "ping",
162
+ "pong",
163
+ "error",
164
+ "cancelled",
165
+ "resume",
166
+ "resume_ack"
167
+ ].includes(msg.t);
168
+ }
169
+ function isProtocolCompatible(senderVersion, receiverVersion) {
170
+ return senderVersion === receiverVersion;
171
+ }
172
+ var P2P_CHUNK_SIZE = 64 * 1024;
173
+ var P2P_MAX_UNACKED_CHUNKS = 32;
174
+ var P2P_END_ACK_TIMEOUT_MS = 15e3;
175
+ var P2P_END_ACK_RETRIES = 3;
176
+ var P2P_END_ACK_RETRY_DELAY_MS = 100;
177
+ var P2P_CLOSE_GRACE_PERIOD_MS = 2e3;
178
+
147
179
  // src/p2p/send.ts
148
180
  function generateSessionId() {
149
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
150
- return crypto.randomUUID();
151
- }
152
- return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
181
+ return crypto.randomUUID();
153
182
  }
183
+ var ALLOWED_TRANSITIONS = {
184
+ initializing: ["listening", "closed"],
185
+ listening: ["handshaking", "closed", "cancelled"],
186
+ handshaking: ["negotiating", "closed", "cancelled"],
187
+ negotiating: ["transferring", "closed", "cancelled"],
188
+ transferring: ["finishing", "closed", "cancelled"],
189
+ finishing: ["awaiting_ack", "closed", "cancelled"],
190
+ awaiting_ack: ["completed", "closed", "cancelled"],
191
+ completed: ["closed"],
192
+ cancelled: ["closed"],
193
+ closed: []
194
+ };
154
195
  async function startP2PSend(opts) {
155
196
  const {
156
197
  file,
@@ -164,21 +205,27 @@ async function startP2PSend(opts) {
164
205
  codeGenerator,
165
206
  cryptoObj,
166
207
  maxAttempts = 4,
167
- chunkSize = 256 * 1024,
168
- endAckTimeoutMs = 15e3,
208
+ chunkSize = P2P_CHUNK_SIZE,
209
+ endAckTimeoutMs = P2P_END_ACK_TIMEOUT_MS,
169
210
  bufferHighWaterMark = 8 * 1024 * 1024,
170
211
  bufferLowWaterMark = 2 * 1024 * 1024,
171
212
  heartbeatIntervalMs = 5e3,
213
+ chunkAcknowledgments = true,
214
+ maxUnackedChunks = P2P_MAX_UNACKED_CHUNKS,
172
215
  onCode,
173
216
  onStatus,
174
217
  onProgress,
175
218
  onComplete,
176
219
  onError,
177
220
  onDisconnect,
178
- onCancel
221
+ onCancel,
222
+ onConnectionHealth
179
223
  } = opts;
180
- if (!file) {
181
- throw new DropgateValidationError("File is missing.");
224
+ const files = Array.isArray(file) ? file : [file];
225
+ const isMultiFile = files.length > 1;
226
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
227
+ if (!files.length) {
228
+ throw new DropgateValidationError("At least one file is required.");
182
229
  }
183
230
  if (!Peer) {
184
231
  throw new DropgateValidationError(
@@ -214,21 +261,35 @@ async function startP2PSend(opts) {
214
261
  let activeConn = null;
215
262
  let sentBytes = 0;
216
263
  let heartbeatTimer = null;
264
+ let healthCheckTimer = null;
265
+ let lastActivityTime = Date.now();
266
+ const unackedChunks = /* @__PURE__ */ new Map();
267
+ let nextSeq = 0;
268
+ let ackResolvers = [];
269
+ const transitionTo = (newState) => {
270
+ if (!ALLOWED_TRANSITIONS[state].includes(newState)) {
271
+ console.warn(`[P2P Send] Invalid state transition: ${state} -> ${newState}`);
272
+ return false;
273
+ }
274
+ state = newState;
275
+ return true;
276
+ };
217
277
  const reportProgress = (data) => {
218
- const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : file.size;
278
+ if (isStopped()) return;
279
+ const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : totalSize;
219
280
  const safeReceived = Math.min(Number(data.received) || 0, safeTotal || 0);
220
281
  const percent = safeTotal ? safeReceived / safeTotal * 100 : 0;
221
282
  onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
222
283
  };
223
284
  const safeError = (err) => {
224
285
  if (state === "closed" || state === "completed" || state === "cancelled") return;
225
- state = "closed";
286
+ transitionTo("closed");
226
287
  onError?.(err);
227
288
  cleanup();
228
289
  };
229
290
  const safeComplete = () => {
230
- if (state !== "finishing") return;
231
- state = "completed";
291
+ if (state !== "awaiting_ack" && state !== "finishing") return;
292
+ transitionTo("completed");
232
293
  onComplete?.();
233
294
  cleanup();
234
295
  };
@@ -237,6 +298,13 @@ async function startP2PSend(opts) {
237
298
  clearInterval(heartbeatTimer);
238
299
  heartbeatTimer = null;
239
300
  }
301
+ if (healthCheckTimer) {
302
+ clearInterval(healthCheckTimer);
303
+ healthCheckTimer = null;
304
+ }
305
+ ackResolvers.forEach((resolve) => resolve());
306
+ ackResolvers = [];
307
+ unackedChunks.clear();
240
308
  if (typeof window !== "undefined") {
241
309
  window.removeEventListener("beforeunload", handleUnload);
242
310
  }
@@ -261,8 +329,12 @@ async function startP2PSend(opts) {
261
329
  }
262
330
  const stop = () => {
263
331
  if (state === "closed" || state === "cancelled") return;
264
- const wasActive = state === "transferring" || state === "finishing";
265
- state = "cancelled";
332
+ if (state === "completed") {
333
+ cleanup();
334
+ return;
335
+ }
336
+ const wasActive = state === "transferring" || state === "finishing" || state === "awaiting_ack";
337
+ transitionTo("cancelled");
266
338
  try {
267
339
  if (activeConn && activeConn.open) {
268
340
  activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
@@ -275,8 +347,91 @@ async function startP2PSend(opts) {
275
347
  cleanup();
276
348
  };
277
349
  const isStopped = () => state === "closed" || state === "cancelled";
350
+ const startHealthMonitoring = (conn) => {
351
+ if (!onConnectionHealth) return;
352
+ healthCheckTimer = setInterval(() => {
353
+ if (isStopped()) return;
354
+ const dc = conn._dc;
355
+ if (!dc) return;
356
+ const health = {
357
+ iceConnectionState: dc.readyState === "open" ? "connected" : "disconnected",
358
+ bufferedAmount: dc.bufferedAmount,
359
+ lastActivityMs: Date.now() - lastActivityTime
360
+ };
361
+ onConnectionHealth(health);
362
+ }, 2e3);
363
+ };
364
+ const handleChunkAck = (msg) => {
365
+ lastActivityTime = Date.now();
366
+ unackedChunks.delete(msg.seq);
367
+ reportProgress({ received: msg.received, total: totalSize });
368
+ const resolver = ackResolvers.shift();
369
+ if (resolver) resolver();
370
+ };
371
+ const waitForAck = () => {
372
+ return new Promise((resolve) => {
373
+ ackResolvers.push(resolve);
374
+ });
375
+ };
376
+ const sendChunk = async (conn, data, offset, fileTotal) => {
377
+ if (chunkAcknowledgments) {
378
+ while (unackedChunks.size >= maxUnackedChunks) {
379
+ await Promise.race([
380
+ waitForAck(),
381
+ sleep(1e3)
382
+ // Timeout to prevent deadlock
383
+ ]);
384
+ if (isStopped()) return;
385
+ }
386
+ }
387
+ const seq = nextSeq++;
388
+ if (chunkAcknowledgments) {
389
+ unackedChunks.set(seq, { offset, size: data.byteLength, sentAt: Date.now() });
390
+ }
391
+ conn.send({ t: "chunk", seq, offset, size: data.byteLength, total: fileTotal ?? totalSize });
392
+ conn.send(data);
393
+ sentBytes += data.byteLength;
394
+ const dc = conn._dc;
395
+ if (dc && bufferHighWaterMark > 0) {
396
+ while (dc.bufferedAmount > bufferHighWaterMark) {
397
+ await new Promise((resolve) => {
398
+ const fallback = setTimeout(resolve, 60);
399
+ try {
400
+ dc.addEventListener(
401
+ "bufferedamountlow",
402
+ () => {
403
+ clearTimeout(fallback);
404
+ resolve();
405
+ },
406
+ { once: true }
407
+ );
408
+ } catch {
409
+ }
410
+ });
411
+ if (isStopped()) return;
412
+ }
413
+ }
414
+ };
415
+ const waitForEndAck = async (conn, ackPromise) => {
416
+ const baseTimeout = endAckTimeoutMs;
417
+ for (let attempt = 0; attempt < P2P_END_ACK_RETRIES; attempt++) {
418
+ conn.send({ t: "end", attempt });
419
+ const timeout = baseTimeout * Math.pow(1.5, attempt);
420
+ const result = await Promise.race([
421
+ ackPromise,
422
+ sleep(timeout).then(() => null)
423
+ ]);
424
+ if (result && result.t === "end_ack") {
425
+ return result;
426
+ }
427
+ if (isStopped()) {
428
+ throw new DropgateNetworkError("Connection closed during completion.");
429
+ }
430
+ }
431
+ throw new DropgateNetworkError("Receiver did not confirm completion after retries.");
432
+ };
278
433
  peer.on("connection", (conn) => {
279
- if (state === "closed") return;
434
+ if (isStopped()) return;
280
435
  if (activeConn) {
281
436
  const isOldConnOpen = activeConn.open !== false;
282
437
  if (isOldConnOpen && state === "transferring") {
@@ -297,6 +452,8 @@ async function startP2PSend(opts) {
297
452
  activeConn = null;
298
453
  state = "listening";
299
454
  sentBytes = 0;
455
+ nextSeq = 0;
456
+ unackedChunks.clear();
300
457
  } else {
301
458
  try {
302
459
  conn.send({ t: "error", message: "Another receiver is already connected." });
@@ -310,60 +467,98 @@ async function startP2PSend(opts) {
310
467
  }
311
468
  }
312
469
  activeConn = conn;
313
- state = "negotiating";
314
- onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
470
+ transitionTo("handshaking");
471
+ if (!isStopped()) onStatus?.({ phase: "connected", message: "Receiver connected." });
472
+ lastActivityTime = Date.now();
473
+ let helloResolve = null;
315
474
  let readyResolve = null;
316
- let ackResolve = null;
475
+ let endAckResolve = null;
476
+ let fileEndAckResolve = null;
477
+ const helloPromise = new Promise((resolve) => {
478
+ helloResolve = resolve;
479
+ });
317
480
  const readyPromise = new Promise((resolve) => {
318
481
  readyResolve = resolve;
319
482
  });
320
- const ackPromise = new Promise((resolve) => {
321
- ackResolve = resolve;
483
+ const endAckPromise = new Promise((resolve) => {
484
+ endAckResolve = resolve;
322
485
  });
323
486
  conn.on("data", (data) => {
324
- if (!data || typeof data !== "object" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
487
+ lastActivityTime = Date.now();
488
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
325
489
  return;
326
490
  }
491
+ if (!isP2PMessage(data)) return;
327
492
  const msg = data;
328
- if (!msg.t) return;
329
- if (msg.t === "ready") {
330
- onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
331
- readyResolve?.();
332
- return;
333
- }
334
- if (msg.t === "progress") {
335
- reportProgress({ received: msg.received || 0, total: msg.total || 0 });
336
- return;
337
- }
338
- if (msg.t === "ack" && msg.phase === "end") {
339
- ackResolve?.(msg);
340
- return;
341
- }
342
- if (msg.t === "pong") {
343
- return;
344
- }
345
- if (msg.t === "error") {
346
- safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
347
- return;
348
- }
349
- if (msg.t === "cancelled") {
350
- if (state === "cancelled" || state === "closed" || state === "completed") return;
351
- state = "cancelled";
352
- onCancel?.({ cancelledBy: "receiver", message: msg.message });
353
- cleanup();
493
+ switch (msg.t) {
494
+ case "hello":
495
+ helloResolve?.(msg.protocolVersion);
496
+ break;
497
+ case "ready":
498
+ if (!isStopped()) onStatus?.({ phase: "transferring", message: "Receiver accepted. Starting transfer..." });
499
+ readyResolve?.();
500
+ break;
501
+ case "chunk_ack":
502
+ handleChunkAck(msg);
503
+ break;
504
+ case "file_end_ack":
505
+ fileEndAckResolve?.(msg);
506
+ break;
507
+ case "end_ack":
508
+ endAckResolve?.(msg);
509
+ break;
510
+ case "pong":
511
+ break;
512
+ case "error":
513
+ safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
514
+ break;
515
+ case "cancelled":
516
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
517
+ transitionTo("cancelled");
518
+ onCancel?.({ cancelledBy: "receiver", message: msg.reason });
519
+ cleanup();
520
+ break;
354
521
  }
355
522
  });
356
523
  conn.on("open", async () => {
357
524
  try {
358
525
  if (isStopped()) return;
526
+ startHealthMonitoring(conn);
527
+ conn.send({
528
+ t: "hello",
529
+ protocolVersion: P2P_PROTOCOL_VERSION,
530
+ sessionId
531
+ });
532
+ const receiverVersion = await Promise.race([
533
+ helloPromise,
534
+ sleep(1e4).then(() => null)
535
+ ]);
536
+ if (isStopped()) return;
537
+ if (receiverVersion === null) {
538
+ throw new DropgateNetworkError("Receiver did not respond to handshake.");
539
+ } else if (receiverVersion !== P2P_PROTOCOL_VERSION) {
540
+ throw new DropgateNetworkError(
541
+ `Protocol version mismatch: sender v${P2P_PROTOCOL_VERSION}, receiver v${receiverVersion}`
542
+ );
543
+ }
544
+ transitionTo("negotiating");
545
+ if (!isStopped()) onStatus?.({ phase: "waiting", message: "Connected. Waiting for receiver to accept..." });
546
+ if (isMultiFile) {
547
+ conn.send({
548
+ t: "file_list",
549
+ fileCount: files.length,
550
+ files: files.map((f) => ({ name: f.name, size: f.size, mime: f.type || "application/octet-stream" })),
551
+ totalSize
552
+ });
553
+ }
359
554
  conn.send({
360
555
  t: "meta",
361
556
  sessionId,
362
- name: file.name,
363
- size: file.size,
364
- mime: file.type || "application/octet-stream"
557
+ name: files[0].name,
558
+ size: files[0].size,
559
+ mime: files[0].type || "application/octet-stream",
560
+ ...isMultiFile ? { fileIndex: 0 } : {}
365
561
  });
366
- const total = file.size;
367
562
  const dc = conn._dc;
368
563
  if (dc && Number.isFinite(bufferLowWaterMark)) {
369
564
  try {
@@ -375,56 +570,60 @@ async function startP2PSend(opts) {
375
570
  if (isStopped()) return;
376
571
  if (heartbeatIntervalMs > 0) {
377
572
  heartbeatTimer = setInterval(() => {
378
- if (state === "transferring" || state === "finishing") {
573
+ if (state === "transferring" || state === "finishing" || state === "awaiting_ack") {
379
574
  try {
380
- conn.send({ t: "ping" });
575
+ conn.send({ t: "ping", timestamp: Date.now() });
381
576
  } catch {
382
577
  }
383
578
  }
384
579
  }, heartbeatIntervalMs);
385
580
  }
386
- state = "transferring";
387
- for (let offset = 0; offset < total; offset += chunkSize) {
388
- if (isStopped()) return;
389
- const slice = file.slice(offset, offset + chunkSize);
390
- const buf = await slice.arrayBuffer();
581
+ transitionTo("transferring");
582
+ let overallSentBytes = 0;
583
+ for (let fi = 0; fi < files.length; fi++) {
584
+ const currentFile = files[fi];
585
+ if (isMultiFile && fi > 0) {
586
+ conn.send({
587
+ t: "meta",
588
+ sessionId,
589
+ name: currentFile.name,
590
+ size: currentFile.size,
591
+ mime: currentFile.type || "application/octet-stream",
592
+ fileIndex: fi
593
+ });
594
+ }
595
+ for (let offset = 0; offset < currentFile.size; offset += chunkSize) {
596
+ if (isStopped()) return;
597
+ const slice = currentFile.slice(offset, offset + chunkSize);
598
+ const buf = await slice.arrayBuffer();
599
+ if (isStopped()) return;
600
+ await sendChunk(conn, buf, offset, currentFile.size);
601
+ overallSentBytes += buf.byteLength;
602
+ reportProgress({ received: overallSentBytes, total: totalSize });
603
+ }
391
604
  if (isStopped()) return;
392
- conn.send(buf);
393
- sentBytes += buf.byteLength;
394
- if (dc) {
395
- while (dc.bufferedAmount > bufferHighWaterMark) {
396
- await new Promise((resolve) => {
397
- const fallback = setTimeout(resolve, 60);
398
- try {
399
- dc.addEventListener(
400
- "bufferedamountlow",
401
- () => {
402
- clearTimeout(fallback);
403
- resolve();
404
- },
405
- { once: true }
406
- );
407
- } catch {
408
- }
409
- });
605
+ if (isMultiFile) {
606
+ const fileEndAckPromise = new Promise((resolve) => {
607
+ fileEndAckResolve = resolve;
608
+ });
609
+ conn.send({ t: "file_end", fileIndex: fi });
610
+ const feAck = await Promise.race([
611
+ fileEndAckPromise,
612
+ sleep(endAckTimeoutMs).then(() => null)
613
+ ]);
614
+ if (isStopped()) return;
615
+ if (!feAck) {
616
+ throw new DropgateNetworkError(`Receiver did not confirm receipt of file ${fi + 1}/${files.length}.`);
410
617
  }
411
618
  }
412
619
  }
413
620
  if (isStopped()) return;
414
- state = "finishing";
415
- conn.send({ t: "end" });
416
- const ackTimeoutMs = Number.isFinite(endAckTimeoutMs) ? Math.max(endAckTimeoutMs, Math.ceil(file.size / (1024 * 1024)) * 1e3) : null;
417
- const ackResult = await Promise.race([
418
- ackPromise,
419
- sleep(ackTimeoutMs || 15e3).catch(() => null)
420
- ]);
621
+ transitionTo("finishing");
622
+ transitionTo("awaiting_ack");
623
+ const ackResult = await waitForEndAck(conn, endAckPromise);
421
624
  if (isStopped()) return;
422
- if (!ackResult || typeof ackResult !== "object") {
423
- throw new DropgateNetworkError("Receiver did not confirm completion.");
424
- }
425
- const ackData = ackResult;
426
- const ackTotal = Number(ackData.total) || file.size;
427
- const ackReceived = Number(ackData.received) || 0;
625
+ const ackTotal = Number(ackResult.total) || totalSize;
626
+ const ackReceived = Number(ackResult.received) || 0;
428
627
  if (ackTotal && ackReceived < ackTotal) {
429
628
  throw new DropgateNetworkError("Receiver reported an incomplete transfer.");
430
629
  }
@@ -442,14 +641,24 @@ async function startP2PSend(opts) {
442
641
  cleanup();
443
642
  return;
444
643
  }
644
+ if (state === "awaiting_ack") {
645
+ setTimeout(() => {
646
+ if (state === "awaiting_ack") {
647
+ safeError(new DropgateNetworkError("Connection closed while awaiting confirmation."));
648
+ }
649
+ }, P2P_CLOSE_GRACE_PERIOD_MS);
650
+ return;
651
+ }
445
652
  if (state === "transferring" || state === "finishing") {
446
- state = "cancelled";
653
+ transitionTo("cancelled");
447
654
  onCancel?.({ cancelledBy: "receiver" });
448
655
  cleanup();
449
656
  } else {
450
657
  activeConn = null;
451
658
  state = "listening";
452
659
  sentBytes = 0;
660
+ nextSeq = 0;
661
+ unackedChunks.clear();
453
662
  onDisconnect?.();
454
663
  }
455
664
  });
@@ -469,6 +678,16 @@ async function startP2PSend(opts) {
469
678
  }
470
679
 
471
680
  // src/p2p/receive.ts
681
+ var ALLOWED_TRANSITIONS2 = {
682
+ initializing: ["connecting", "closed"],
683
+ connecting: ["handshaking", "closed", "cancelled"],
684
+ handshaking: ["negotiating", "closed", "cancelled"],
685
+ negotiating: ["transferring", "closed", "cancelled"],
686
+ transferring: ["completed", "closed", "cancelled"],
687
+ completed: ["closed"],
688
+ cancelled: ["closed"],
689
+ closed: []
690
+ };
472
691
  async function startP2PReceive(opts) {
473
692
  const {
474
693
  code,
@@ -485,6 +704,8 @@ async function startP2PReceive(opts) {
485
704
  onMeta,
486
705
  onData,
487
706
  onProgress,
707
+ onFileStart,
708
+ onFileEnd,
488
709
  onComplete,
489
710
  onError,
490
711
  onDisconnect,
@@ -522,11 +743,22 @@ async function startP2PReceive(opts) {
522
743
  let total = 0;
523
744
  let received = 0;
524
745
  let currentSessionId = null;
525
- let lastProgressSentAt = 0;
526
- const progressIntervalMs = 120;
527
746
  let writeQueue = Promise.resolve();
528
747
  let watchdogTimer = null;
529
748
  let activeConn = null;
749
+ let pendingChunk = null;
750
+ let fileList = null;
751
+ let currentFileReceived = 0;
752
+ let totalReceivedAllFiles = 0;
753
+ const transitionTo = (newState) => {
754
+ if (!ALLOWED_TRANSITIONS2[state].includes(newState)) {
755
+ console.warn(`[P2P Receive] Invalid state transition: ${state} -> ${newState}`);
756
+ return false;
757
+ }
758
+ state = newState;
759
+ return true;
760
+ };
761
+ const isStopped = () => state === "closed" || state === "cancelled";
530
762
  const resetWatchdog = () => {
531
763
  if (watchdogTimeoutMs <= 0) return;
532
764
  if (watchdogTimer) {
@@ -546,15 +778,14 @@ async function startP2PReceive(opts) {
546
778
  };
547
779
  const safeError = (err) => {
548
780
  if (state === "closed" || state === "completed" || state === "cancelled") return;
549
- state = "closed";
781
+ transitionTo("closed");
550
782
  onError?.(err);
551
783
  cleanup();
552
784
  };
553
785
  const safeComplete = (completeData) => {
554
786
  if (state !== "transferring") return;
555
- state = "completed";
787
+ transitionTo("completed");
556
788
  onComplete?.(completeData);
557
- cleanup();
558
789
  };
559
790
  const cleanup = () => {
560
791
  clearWatchdog();
@@ -578,11 +809,15 @@ async function startP2PReceive(opts) {
578
809
  }
579
810
  const stop = () => {
580
811
  if (state === "closed" || state === "cancelled") return;
812
+ if (state === "completed") {
813
+ cleanup();
814
+ return;
815
+ }
581
816
  const wasActive = state === "transferring";
582
- state = "cancelled";
817
+ transitionTo("cancelled");
583
818
  try {
584
819
  if (activeConn && activeConn.open) {
585
- activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
820
+ activeConn.send({ t: "cancelled", reason: "Receiver cancelled the transfer." });
586
821
  }
587
822
  } catch {
588
823
  }
@@ -591,23 +826,88 @@ async function startP2PReceive(opts) {
591
826
  }
592
827
  cleanup();
593
828
  };
829
+ const sendChunkAck = (conn, seq) => {
830
+ try {
831
+ conn.send({ t: "chunk_ack", seq, received });
832
+ } catch {
833
+ }
834
+ };
594
835
  peer.on("error", (err) => {
595
836
  safeError(err);
596
837
  });
597
838
  peer.on("open", () => {
598
- state = "connecting";
839
+ transitionTo("connecting");
599
840
  const conn = peer.connect(normalizedCode, { reliable: true });
600
841
  activeConn = conn;
601
842
  conn.on("open", () => {
602
- state = "negotiating";
603
- onStatus?.({ phase: "connected", message: "Waiting for file details..." });
843
+ transitionTo("handshaking");
844
+ onStatus?.({ phase: "connected", message: "Connected." });
845
+ conn.send({
846
+ t: "hello",
847
+ protocolVersion: P2P_PROTOCOL_VERSION,
848
+ sessionId: ""
849
+ });
604
850
  });
605
851
  conn.on("data", async (data) => {
606
852
  try {
607
853
  resetWatchdog();
608
- if (data && typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
609
- const msg = data;
610
- if (msg.t === "meta") {
854
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || typeof Blob !== "undefined" && data instanceof Blob) {
855
+ let bufPromise;
856
+ if (data instanceof ArrayBuffer) {
857
+ bufPromise = Promise.resolve(new Uint8Array(data));
858
+ } else if (ArrayBuffer.isView(data)) {
859
+ bufPromise = Promise.resolve(
860
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
861
+ );
862
+ } else if (typeof Blob !== "undefined" && data instanceof Blob) {
863
+ bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
864
+ } else {
865
+ return;
866
+ }
867
+ const chunkSeq = pendingChunk?.seq ?? -1;
868
+ pendingChunk = null;
869
+ writeQueue = writeQueue.then(async () => {
870
+ const buf = await bufPromise;
871
+ if (onData) {
872
+ await onData(buf);
873
+ }
874
+ received += buf.byteLength;
875
+ currentFileReceived += buf.byteLength;
876
+ const progressReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
877
+ const progressTotal = fileList ? fileList.totalSize : total;
878
+ const percent = progressTotal ? Math.min(100, progressReceived / progressTotal * 100) : 0;
879
+ if (!isStopped()) onProgress?.({ processedBytes: progressReceived, totalBytes: progressTotal, percent });
880
+ if (chunkSeq >= 0) {
881
+ sendChunkAck(conn, chunkSeq);
882
+ }
883
+ }).catch((err) => {
884
+ try {
885
+ conn.send({
886
+ t: "error",
887
+ message: err?.message || "Receiver write failed."
888
+ });
889
+ } catch {
890
+ }
891
+ safeError(err);
892
+ });
893
+ return;
894
+ }
895
+ if (!isP2PMessage(data)) return;
896
+ const msg = data;
897
+ switch (msg.t) {
898
+ case "hello":
899
+ currentSessionId = msg.sessionId || null;
900
+ transitionTo("negotiating");
901
+ onStatus?.({ phase: "waiting", message: "Waiting for file details..." });
902
+ break;
903
+ case "file_list":
904
+ fileList = msg;
905
+ total = fileList.totalSize;
906
+ break;
907
+ case "meta": {
908
+ if (state !== "negotiating" && !(state === "transferring" && fileList)) {
909
+ return;
910
+ }
611
911
  if (currentSessionId && msg.sessionId && msg.sessionId !== currentSessionId) {
612
912
  try {
613
913
  conn.send({ t: "error", message: "Busy with another session." });
@@ -619,40 +919,83 @@ async function startP2PReceive(opts) {
619
919
  currentSessionId = msg.sessionId;
620
920
  }
621
921
  const name = String(msg.name || "file");
622
- total = Number(msg.size) || 0;
922
+ const fileSize = Number(msg.size) || 0;
923
+ const fi = msg.fileIndex;
924
+ if (fileList && typeof fi === "number" && fi > 0) {
925
+ currentFileReceived = 0;
926
+ onFileStart?.({ fileIndex: fi, name, size: fileSize });
927
+ break;
928
+ }
623
929
  received = 0;
930
+ currentFileReceived = 0;
931
+ totalReceivedAllFiles = 0;
932
+ if (!fileList) {
933
+ total = fileSize;
934
+ }
624
935
  writeQueue = Promise.resolve();
625
936
  const sendReady = () => {
626
- state = "transferring";
937
+ transitionTo("transferring");
627
938
  resetWatchdog();
939
+ if (fileList) {
940
+ onFileStart?.({ fileIndex: 0, name, size: fileSize });
941
+ }
628
942
  try {
629
943
  conn.send({ t: "ready" });
630
944
  } catch {
631
945
  }
632
946
  };
947
+ const metaEvt = { name, total };
948
+ if (fileList) {
949
+ metaEvt.fileCount = fileList.fileCount;
950
+ metaEvt.files = fileList.files.map((f) => ({ name: f.name, size: f.size }));
951
+ metaEvt.totalSize = fileList.totalSize;
952
+ }
633
953
  if (autoReady) {
634
- onMeta?.({ name, total });
635
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
954
+ if (!isStopped()) {
955
+ onMeta?.(metaEvt);
956
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
957
+ }
636
958
  sendReady();
637
959
  } else {
638
- onMeta?.({ name, total, sendReady });
639
- onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
960
+ metaEvt.sendReady = sendReady;
961
+ if (!isStopped()) {
962
+ onMeta?.(metaEvt);
963
+ onProgress?.({ processedBytes: received, totalBytes: total, percent: 0 });
964
+ }
640
965
  }
641
- return;
966
+ break;
642
967
  }
643
- if (msg.t === "ping") {
968
+ case "chunk":
969
+ pendingChunk = msg;
970
+ break;
971
+ case "ping":
644
972
  try {
645
- conn.send({ t: "pong" });
973
+ conn.send({ t: "pong", timestamp: Date.now() });
646
974
  } catch {
647
975
  }
648
- return;
976
+ break;
977
+ case "file_end": {
978
+ clearWatchdog();
979
+ await writeQueue;
980
+ const feIdx = msg.fileIndex;
981
+ onFileEnd?.({ fileIndex: feIdx, receivedBytes: currentFileReceived });
982
+ try {
983
+ conn.send({ t: "file_end_ack", fileIndex: feIdx, received: currentFileReceived, size: currentFileReceived });
984
+ } catch {
985
+ }
986
+ totalReceivedAllFiles += currentFileReceived;
987
+ currentFileReceived = 0;
988
+ resetWatchdog();
989
+ break;
649
990
  }
650
- if (msg.t === "end") {
991
+ case "end":
651
992
  clearWatchdog();
652
993
  await writeQueue;
653
- if (total && received < total) {
994
+ const finalReceived = fileList ? totalReceivedAllFiles + currentFileReceived : received;
995
+ const finalTotal = fileList ? fileList.totalSize : total;
996
+ if (finalTotal && finalReceived < finalTotal) {
654
997
  const err = new DropgateNetworkError(
655
- "Transfer ended before the full file was received."
998
+ "Transfer ended before all data was received."
656
999
  );
657
1000
  try {
658
1001
  conn.send({ t: "error", message: err.message });
@@ -661,62 +1004,31 @@ async function startP2PReceive(opts) {
661
1004
  throw err;
662
1005
  }
663
1006
  try {
664
- conn.send({ t: "ack", phase: "end", received, total });
1007
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
665
1008
  } catch {
666
1009
  }
667
- safeComplete({ received, total });
668
- return;
669
- }
670
- if (msg.t === "error") {
1010
+ safeComplete({ received: finalReceived, total: finalTotal });
1011
+ (async () => {
1012
+ for (let i = 0; i < 2; i++) {
1013
+ await sleep(P2P_END_ACK_RETRY_DELAY_MS);
1014
+ try {
1015
+ conn.send({ t: "end_ack", received: finalReceived, total: finalTotal });
1016
+ } catch {
1017
+ break;
1018
+ }
1019
+ }
1020
+ })().catch(() => {
1021
+ });
1022
+ break;
1023
+ case "error":
671
1024
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
672
- }
673
- if (msg.t === "cancelled") {
1025
+ case "cancelled":
674
1026
  if (state === "cancelled" || state === "closed" || state === "completed") return;
675
- state = "cancelled";
676
- onCancel?.({ cancelledBy: "sender", message: msg.message });
1027
+ transitionTo("cancelled");
1028
+ onCancel?.({ cancelledBy: "sender", message: msg.reason });
677
1029
  cleanup();
678
- return;
679
- }
680
- return;
681
- }
682
- let bufPromise;
683
- if (data instanceof ArrayBuffer) {
684
- bufPromise = Promise.resolve(new Uint8Array(data));
685
- } else if (ArrayBuffer.isView(data)) {
686
- bufPromise = Promise.resolve(
687
- new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
688
- );
689
- } else if (typeof Blob !== "undefined" && data instanceof Blob) {
690
- bufPromise = data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
691
- } else {
692
- return;
1030
+ break;
693
1031
  }
694
- writeQueue = writeQueue.then(async () => {
695
- const buf = await bufPromise;
696
- if (onData) {
697
- await onData(buf);
698
- }
699
- received += buf.byteLength;
700
- const percent = total ? Math.min(100, received / total * 100) : 0;
701
- onProgress?.({ processedBytes: received, totalBytes: total, percent });
702
- const now = Date.now();
703
- if (received === total || now - lastProgressSentAt >= progressIntervalMs) {
704
- lastProgressSentAt = now;
705
- try {
706
- conn.send({ t: "progress", received, total });
707
- } catch {
708
- }
709
- }
710
- }).catch((err) => {
711
- try {
712
- conn.send({
713
- t: "error",
714
- message: err?.message || "Receiver write failed."
715
- });
716
- } catch {
717
- }
718
- safeError(err);
719
- });
720
1032
  } catch (err) {
721
1033
  safeError(err);
722
1034
  }
@@ -727,11 +1039,11 @@ async function startP2PReceive(opts) {
727
1039
  return;
728
1040
  }
729
1041
  if (state === "transferring") {
730
- state = "cancelled";
1042
+ transitionTo("cancelled");
731
1043
  onCancel?.({ cancelledBy: "sender" });
732
1044
  cleanup();
733
1045
  } else if (state === "negotiating") {
734
- state = "closed";
1046
+ transitionTo("closed");
735
1047
  cleanup();
736
1048
  onDisconnect?.();
737
1049
  } else {
@@ -749,11 +1061,18 @@ async function startP2PReceive(opts) {
749
1061
  };
750
1062
  }
751
1063
  export {
1064
+ P2P_CHUNK_SIZE,
1065
+ P2P_END_ACK_RETRIES,
1066
+ P2P_END_ACK_TIMEOUT_MS,
1067
+ P2P_MAX_UNACKED_CHUNKS,
1068
+ P2P_PROTOCOL_VERSION,
752
1069
  buildPeerOptions,
753
1070
  createPeerWithRetries,
754
1071
  generateP2PCode,
755
1072
  isLocalhostHostname,
756
1073
  isP2PCodeLike,
1074
+ isP2PMessage,
1075
+ isProtocolCompatible,
757
1076
  isSecureContextForP2P,
758
1077
  resolvePeerConfig,
759
1078
  startP2PReceive,