dropspace 0.0.0-stage → 0.1.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.
Files changed (67) hide show
  1. package/README.md +17 -2
  2. package/dist/automerge/index.d.ts +108 -0
  3. package/dist/automerge/index.d.ts.map +1 -0
  4. package/dist/automerge/index.js +437 -0
  5. package/dist/automerge/index.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +372 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/client/channel.d.ts +153 -0
  11. package/dist/client/channel.d.ts.map +1 -0
  12. package/dist/client/channel.js +704 -0
  13. package/dist/client/channel.js.map +1 -0
  14. package/dist/client/client.d.ts +376 -0
  15. package/dist/client/client.d.ts.map +1 -0
  16. package/dist/client/client.js +1421 -0
  17. package/dist/client/client.js.map +1 -0
  18. package/dist/client/events.d.ts +14 -0
  19. package/dist/client/events.d.ts.map +1 -0
  20. package/dist/client/events.js +71 -0
  21. package/dist/client/events.js.map +1 -0
  22. package/dist/client/identity.d.ts +89 -0
  23. package/dist/client/identity.d.ts.map +1 -0
  24. package/dist/client/identity.js +274 -0
  25. package/dist/client/identity.js.map +1 -0
  26. package/dist/client/index.d.ts +8 -0
  27. package/dist/client/index.d.ts.map +1 -0
  28. package/dist/client/index.js +8 -0
  29. package/dist/client/index.js.map +1 -0
  30. package/dist/client/peer-connection.d.ts +190 -0
  31. package/dist/client/peer-connection.d.ts.map +1 -0
  32. package/dist/client/peer-connection.js +647 -0
  33. package/dist/client/peer-connection.js.map +1 -0
  34. package/dist/client/peer-selection.d.ts +70 -0
  35. package/dist/client/peer-selection.d.ts.map +1 -0
  36. package/dist/client/peer-selection.js +193 -0
  37. package/dist/client/peer-selection.js.map +1 -0
  38. package/dist/client/storage.d.ts +118 -0
  39. package/dist/client/storage.d.ts.map +1 -0
  40. package/dist/client/storage.js +463 -0
  41. package/dist/client/storage.js.map +1 -0
  42. package/dist/files/index.d.ts +140 -0
  43. package/dist/files/index.d.ts.map +1 -0
  44. package/dist/files/index.js +751 -0
  45. package/dist/files/index.js.map +1 -0
  46. package/dist/files/sha256.d.ts +24 -0
  47. package/dist/files/sha256.d.ts.map +1 -0
  48. package/dist/files/sha256.js +147 -0
  49. package/dist/files/sha256.js.map +1 -0
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +4 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/protocol/envelope.d.ts +61 -0
  55. package/dist/protocol/envelope.d.ts.map +1 -0
  56. package/dist/protocol/envelope.js +268 -0
  57. package/dist/protocol/envelope.js.map +1 -0
  58. package/dist/protocol/framing.d.ts +69 -0
  59. package/dist/protocol/framing.d.ts.map +1 -0
  60. package/dist/protocol/framing.js +145 -0
  61. package/dist/protocol/framing.js.map +1 -0
  62. package/dist/protocol/index.d.ts +3 -0
  63. package/dist/protocol/index.d.ts.map +1 -0
  64. package/dist/protocol/index.js +3 -0
  65. package/dist/protocol/index.js.map +1 -0
  66. package/fixtures/envelopes.json +132 -0
  67. package/package.json +51 -4
@@ -0,0 +1,751 @@
1
+ import { WireFrameType, encodeJsonPayload, decodeJsonPayload, } from "../protocol/index.js";
2
+ import { TypedEventEmitter, DropspaceStorageError, sanitizeFileName, InMemoryBinaryStagingAdapter, } from "../client/index.js";
3
+ import { StreamingSha256, sha256HexSync } from "./sha256.js";
4
+ export { StreamingSha256, sha256HexSync };
5
+ export class FileTransferError extends Error {
6
+ code;
7
+ transferId;
8
+ constructor(message, code, transferId) {
9
+ super(message);
10
+ this.code = code;
11
+ this.transferId = transferId;
12
+ this.name = "FileTransferError";
13
+ }
14
+ }
15
+ export function createBlobFileSource(blob, overrideName) {
16
+ const name = overrideName ?? blob.name ?? "unnamed.bin";
17
+ const lastModified = blob.lastModified ?? 0;
18
+ const mimeType = blob.type || "application/octet-stream";
19
+ return {
20
+ get name() {
21
+ return name;
22
+ },
23
+ get size() {
24
+ return blob.size;
25
+ },
26
+ get mimeType() {
27
+ return mimeType;
28
+ },
29
+ get lastModified() {
30
+ return blob.lastModified ?? lastModified;
31
+ },
32
+ async readSlice(start, end) {
33
+ const slice = blob.slice(start, end);
34
+ return new Uint8Array(await slice.arrayBuffer());
35
+ },
36
+ async computeSha256() {
37
+ const total = blob.size;
38
+ if (total <= 64 * 1024 * 1024 &&
39
+ typeof globalThis.crypto?.subtle?.digest === "function") {
40
+ const buf = total === 0 ? new ArrayBuffer(0) : await blob.arrayBuffer();
41
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", buf);
42
+ const bytes = new Uint8Array(digest);
43
+ let hex = "";
44
+ for (let i = 0; i < bytes.length; i++) {
45
+ hex += bytes[i].toString(16).padStart(2, "0");
46
+ }
47
+ return hex;
48
+ }
49
+ const hasher = new StreamingSha256();
50
+ const readSize = 4 * 1024 * 1024;
51
+ let offset = 0;
52
+ let sinceYield = 0;
53
+ while (offset < total) {
54
+ const end = Math.min(total, offset + readSize);
55
+ const buf = new Uint8Array(await blob.slice(offset, end).arrayBuffer());
56
+ hasher.update(buf);
57
+ sinceYield += end - offset;
58
+ offset = end;
59
+ if (sinceYield >= 16 * 1024 * 1024 && offset < total) {
60
+ sinceYield = 0;
61
+ await new Promise((resolve) => setTimeout(resolve, 0));
62
+ }
63
+ }
64
+ return hasher.digestHex();
65
+ },
66
+ };
67
+ }
68
+ let transferSeq = 0;
69
+ function nextTransferId() {
70
+ transferSeq = (transferSeq + 1) % 1_000_000_000;
71
+ return `xfer_${Date.now().toString(36)}_${transferSeq.toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
72
+ }
73
+ /**
74
+ * Bounded-memory, chunk-tracked, pausable and resumable file transfer engine over Dropspace DataChannels.
75
+ */
76
+ export class FileTransferManager {
77
+ options;
78
+ events = new TypedEventEmitter();
79
+ storage;
80
+ chunkSize;
81
+ policy;
82
+ outgoing = new Map();
83
+ incoming = new Map();
84
+ unsubscribeClient;
85
+ incomingFrameQueues = new Map();
86
+ constructor(options) {
87
+ this.options = options;
88
+ this.storage = options.storage ?? new InMemoryBinaryStagingAdapter();
89
+ this.chunkSize = options.chunkSize ?? 32 * 1024; // 32 KiB chunks
90
+ this.policy = {
91
+ maxFileSizeBytes: options.policy?.maxFileSizeBytes ?? 500 * 1024 * 1024,
92
+ allowedMimeTypes: options.policy?.allowedMimeTypes,
93
+ allowOverwrite: options.policy?.allowOverwrite ?? false,
94
+ autoAccept: options.policy?.autoAccept ?? true,
95
+ onIncomingOffer: options.policy?.onIncomingOffer,
96
+ };
97
+ this.unsubscribeClient = this.options.client.events.on("transferFrame", ({ peerDeviceId, frame }) => {
98
+ const lockKey = `${peerDeviceId}:${frame.header.transferId ?? "__control__"}`;
99
+ const prev = this.incomingFrameQueues.get(lockKey) ?? Promise.resolve();
100
+ const next = prev
101
+ .then(() => this.handleIncomingFrame(peerDeviceId, frame))
102
+ .catch(() => { });
103
+ this.incomingFrameQueues.set(lockKey, next);
104
+ });
105
+ }
106
+ /**
107
+ * Computes the SHA-256 hash of a `SliceableFileSource` in bounded memory (one chunk at a time).
108
+ */
109
+ async computeSourceSha256(source, chunkSize = this.chunkSize) {
110
+ if (source.computeSha256) {
111
+ return source.computeSha256();
112
+ }
113
+ const hasher = new StreamingSha256();
114
+ let offset = 0;
115
+ while (offset < source.size) {
116
+ const end = Math.min(source.size, offset + chunkSize);
117
+ const slice = await source.readSlice(offset, end);
118
+ hasher.update(slice);
119
+ offset = end;
120
+ }
121
+ return hasher.digestHex();
122
+ }
123
+ /**
124
+ * Verifies that the underlying file source has not mutated since the transfer started.
125
+ */
126
+ async verifySourceUnchanged(rt) {
127
+ if (rt.source.size !== rt.initialSize ||
128
+ rt.source.lastModified !== rt.initialLastModified) {
129
+ throw new FileTransferError(`Source file '${rt.manifest.fileName}' changed size or lastModified timestamp before resume`, "SOURCE_FILE_MODIFIED", rt.manifest.transferId);
130
+ }
131
+ const currentHash = await this.computeSourceSha256(rt.source, rt.manifest.chunkSize);
132
+ if (currentHash !== rt.manifest.fileSha256) {
133
+ throw new FileTransferError(`Source file '${rt.manifest.fileName}' content SHA-256 changed before resume`, "SOURCE_FILE_MODIFIED", rt.manifest.transferId);
134
+ }
135
+ }
136
+ async sendFile(peerDeviceId, sourceInput, options) {
137
+ const source = "readSlice" in sourceInput ? sourceInput : createBlobFileSource(sourceInput);
138
+ const safeFileName = sanitizeFileName(source.name);
139
+ const chunkSize = options?.chunkSize ?? this.chunkSize;
140
+ const totalBytes = source.size;
141
+ const totalChunks = Math.max(1, Math.ceil(totalBytes / chunkSize));
142
+ const transferId = options?.transferId ?? nextTransferId();
143
+ const fileSha256 = await this.computeSourceSha256(source, chunkSize);
144
+ const now = Date.now();
145
+ const manifest = {
146
+ transferId,
147
+ peerDeviceId,
148
+ direction: "send",
149
+ fileName: safeFileName,
150
+ mimeType: source.mimeType,
151
+ totalBytes,
152
+ chunkSize,
153
+ totalChunks,
154
+ fileSha256,
155
+ lastModified: source.lastModified,
156
+ state: "offered",
157
+ completedChunks: [],
158
+ transferredBytes: 0,
159
+ startedAt: now,
160
+ updatedAt: now,
161
+ };
162
+ const rt = {
163
+ manifest,
164
+ source,
165
+ initialSize: source.size,
166
+ initialLastModified: source.lastModified,
167
+ paused: false,
168
+ cancelled: false,
169
+ ackedChunks: new Set(),
170
+ sendingLoopPromise: null,
171
+ completionWaiters: [],
172
+ };
173
+ this.outgoing.set(transferId, rt);
174
+ if (options?.signal) {
175
+ options.signal.addEventListener("abort", () => {
176
+ void this.cancelTransfer(transferId, "Aborted by caller AbortSignal");
177
+ }, { once: true });
178
+ }
179
+ await this.sendControlFrame(peerDeviceId, transferId, {
180
+ action: "offer",
181
+ fileName: manifest.fileName,
182
+ mimeType: manifest.mimeType,
183
+ totalBytes: manifest.totalBytes,
184
+ chunkSize: manifest.chunkSize,
185
+ totalChunks: manifest.totalChunks,
186
+ fileSha256: manifest.fileSha256,
187
+ lastModified: manifest.lastModified,
188
+ });
189
+ this.events.emit("stateChange", { ...manifest });
190
+ if (options?.waitForCompletion ?? true) {
191
+ return new Promise((resolve, reject) => {
192
+ if (manifest.state === "completed") {
193
+ resolve({ ...manifest });
194
+ return;
195
+ }
196
+ if (manifest.state === "failed" || manifest.state === "cancelled") {
197
+ reject(new FileTransferError(manifest.error?.message ?? "Transfer failed", manifest.error?.code ?? "CANCELLED", transferId));
198
+ return;
199
+ }
200
+ rt.completionWaiters.push({ resolve, reject });
201
+ });
202
+ }
203
+ return { ...manifest };
204
+ }
205
+ async pauseTransfer(transferId) {
206
+ const out = this.outgoing.get(transferId);
207
+ if (out) {
208
+ out.paused = true;
209
+ out.manifest.state = "paused";
210
+ out.manifest.updatedAt = Date.now();
211
+ this.events.emit("stateChange", { ...out.manifest });
212
+ await this.sendControlFrame(out.manifest.peerDeviceId, transferId, {
213
+ action: "pause",
214
+ }).catch(() => { });
215
+ return;
216
+ }
217
+ const inc = this.incoming.get(transferId);
218
+ if (inc) {
219
+ inc.paused = true;
220
+ inc.manifest.state = "paused";
221
+ inc.manifest.updatedAt = Date.now();
222
+ this.events.emit("stateChange", { ...inc.manifest });
223
+ await this.sendControlFrame(inc.manifest.peerDeviceId, transferId, {
224
+ action: "pause",
225
+ }).catch(() => { });
226
+ }
227
+ }
228
+ /**
229
+ * Resumes a paused or interrupted transfer from the exact missing chunks without re-sending
230
+ * chunks that have already been persisted on the receiver.
231
+ * Before resuming on the sender side, verifies that the source file has not been modified!
232
+ */
233
+ async resumeTransfer(transferId) {
234
+ const out = this.outgoing.get(transferId);
235
+ if (out) {
236
+ try {
237
+ await this.verifySourceUnchanged(out);
238
+ }
239
+ catch (err) {
240
+ const ftErr = err instanceof FileTransferError
241
+ ? err
242
+ : new FileTransferError(String(err), "SOURCE_FILE_MODIFIED", transferId);
243
+ this.failOutgoingTransfer(out, ftErr);
244
+ throw ftErr;
245
+ }
246
+ out.paused = false;
247
+ // Query receiver for its persisted chunk bitmap so we only send missing chunks
248
+ await this.sendControlFrame(out.manifest.peerDeviceId, transferId, {
249
+ action: "offer",
250
+ fileName: out.manifest.fileName,
251
+ mimeType: out.manifest.mimeType,
252
+ totalBytes: out.manifest.totalBytes,
253
+ chunkSize: out.manifest.chunkSize,
254
+ totalChunks: out.manifest.totalChunks,
255
+ fileSha256: out.manifest.fileSha256,
256
+ lastModified: out.manifest.lastModified,
257
+ isResume: true,
258
+ });
259
+ return;
260
+ }
261
+ const inc = this.incoming.get(transferId);
262
+ if (inc) {
263
+ inc.paused = false;
264
+ inc.manifest.state = "transferring";
265
+ inc.manifest.updatedAt = Date.now();
266
+ const persisted = await this.storage.listWrittenChunks(transferId);
267
+ for (const idx of persisted) {
268
+ inc.receivedChunks.add(idx);
269
+ }
270
+ this.events.emit("stateChange", { ...inc.manifest });
271
+ await this.sendControlFrame(inc.manifest.peerDeviceId, transferId, {
272
+ action: "accept",
273
+ receivedChunks: Array.from(inc.receivedChunks),
274
+ });
275
+ }
276
+ }
277
+ async cancelTransfer(transferId, reason = "Cancelled by user") {
278
+ const out = this.outgoing.get(transferId);
279
+ if (out) {
280
+ out.cancelled = true;
281
+ out.paused = false;
282
+ out.manifest.state = "cancelled";
283
+ out.manifest.error = { code: "CANCELLED", message: reason };
284
+ out.manifest.updatedAt = Date.now();
285
+ this.events.emit("stateChange", { ...out.manifest });
286
+ await this.sendControlFrame(out.manifest.peerDeviceId, transferId, {
287
+ action: "cancel",
288
+ reason,
289
+ }).catch(() => { });
290
+ for (const w of out.completionWaiters.splice(0)) {
291
+ w.reject(new FileTransferError(reason, "CANCELLED", transferId));
292
+ }
293
+ }
294
+ const inc = this.incoming.get(transferId);
295
+ if (inc) {
296
+ inc.cancelled = true;
297
+ inc.manifest.state = "cancelled";
298
+ inc.manifest.error = { code: "CANCELLED", message: reason };
299
+ inc.manifest.updatedAt = Date.now();
300
+ await this.storage.deleteTransferStaging(transferId);
301
+ this.events.emit("stateChange", { ...inc.manifest });
302
+ await this.sendControlFrame(inc.manifest.peerDeviceId, transferId, {
303
+ action: "cancel",
304
+ reason,
305
+ }).catch(() => { });
306
+ }
307
+ }
308
+ syncCompletedChunks(manifest, chunks) {
309
+ if (manifest.completedChunks.length !== chunks.size) {
310
+ manifest.completedChunks = Array.from(chunks).sort((a, b) => a - b);
311
+ }
312
+ return { ...manifest };
313
+ }
314
+ getTransfer(transferId) {
315
+ const out = this.outgoing.get(transferId);
316
+ if (out)
317
+ return this.syncCompletedChunks(out.manifest, out.ackedChunks);
318
+ const inc = this.incoming.get(transferId);
319
+ if (inc)
320
+ return this.syncCompletedChunks(inc.manifest, inc.receivedChunks);
321
+ return undefined;
322
+ }
323
+ listTransfers() {
324
+ return [
325
+ ...Array.from(this.outgoing.values()).map((o) => this.syncCompletedChunks(o.manifest, o.ackedChunks)),
326
+ ...Array.from(this.incoming.values()).map((i) => this.syncCompletedChunks(i.manifest, i.receivedChunks)),
327
+ ];
328
+ }
329
+ async sendControlFrame(peerDeviceId, transferId, controlPayload) {
330
+ const sess = this.options.client.getPeerSession(peerDeviceId);
331
+ if (!sess || sess.getState() !== "connected") {
332
+ throw new FileTransferError(`Peer '${peerDeviceId}' is not connected`, "PEER_DISCONNECTED", transferId);
333
+ }
334
+ await sess.multiplexer.sendRawWireFrame(WireFrameType.TRANSFER_CONTROL, {
335
+ messageId: `tctrl_${transferId}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
336
+ channel: "files",
337
+ timestamp: Date.now(),
338
+ transferId,
339
+ }, encodeJsonPayload(controlPayload));
340
+ }
341
+ async handleIncomingFrame(peerDeviceId, frame) {
342
+ const transferId = frame.header.transferId;
343
+ if (!transferId)
344
+ return;
345
+ if (frame.frameType === WireFrameType.TRANSFER_CONTROL) {
346
+ const ctrl = decodeJsonPayload(frame.payload);
347
+ switch (ctrl.action) {
348
+ case "offer": {
349
+ await this.handleIncomingOffer(peerDeviceId, transferId, ctrl);
350
+ break;
351
+ }
352
+ case "accept": {
353
+ const out = this.outgoing.get(transferId);
354
+ if (!out || out.cancelled)
355
+ break;
356
+ if (Array.isArray(ctrl.receivedChunks)) {
357
+ for (const idx of ctrl.receivedChunks) {
358
+ out.ackedChunks.add(idx);
359
+ }
360
+ out.manifest.completedChunks = Array.from(out.ackedChunks).sort((a, b) => a - b);
361
+ }
362
+ out.paused = false;
363
+ out.manifest.state = "transferring";
364
+ out.manifest.updatedAt = Date.now();
365
+ this.events.emit("stateChange", { ...out.manifest });
366
+ void this.pumpOutgoingChunks(out);
367
+ break;
368
+ }
369
+ case "chunk_ack": {
370
+ const out = this.outgoing.get(transferId);
371
+ if (!out || typeof ctrl.chunkIndex !== "number")
372
+ break;
373
+ out.ackedChunks.add(ctrl.chunkIndex);
374
+ out.manifest.completedChunks = Array.from(out.ackedChunks).sort((a, b) => a - b);
375
+ break;
376
+ }
377
+ case "pause": {
378
+ const out = this.outgoing.get(transferId);
379
+ if (out) {
380
+ out.paused = true;
381
+ out.manifest.state = "paused";
382
+ out.manifest.updatedAt = Date.now();
383
+ this.events.emit("stateChange", { ...out.manifest });
384
+ }
385
+ const inc = this.incoming.get(transferId);
386
+ if (inc) {
387
+ inc.paused = true;
388
+ inc.manifest.state = "paused";
389
+ inc.manifest.updatedAt = Date.now();
390
+ this.events.emit("stateChange", { ...inc.manifest });
391
+ }
392
+ break;
393
+ }
394
+ case "complete": {
395
+ const out = this.outgoing.get(transferId);
396
+ if (!out)
397
+ break;
398
+ out.manifest.state = "completed";
399
+ out.manifest.transferredBytes = out.manifest.totalBytes;
400
+ out.manifest.updatedAt = Date.now();
401
+ this.events.emit("stateChange", { ...out.manifest });
402
+ this.events.emit("completed", { manifest: { ...out.manifest } });
403
+ for (const w of out.completionWaiters.splice(0)) {
404
+ w.resolve({ ...out.manifest });
405
+ }
406
+ break;
407
+ }
408
+ case "cancel": {
409
+ const reason = ctrl.reason ?? "Cancelled by remote peer";
410
+ const out = this.outgoing.get(transferId);
411
+ if (out) {
412
+ out.cancelled = true;
413
+ out.manifest.state = "cancelled";
414
+ out.manifest.error = { code: "CANCELLED", message: reason };
415
+ this.events.emit("stateChange", { ...out.manifest });
416
+ for (const w of out.completionWaiters.splice(0)) {
417
+ w.reject(new FileTransferError(reason, "CANCELLED", transferId));
418
+ }
419
+ }
420
+ const inc = this.incoming.get(transferId);
421
+ if (inc) {
422
+ inc.cancelled = true;
423
+ inc.manifest.state = "cancelled";
424
+ inc.manifest.error = { code: "CANCELLED", message: reason };
425
+ await this.storage.deleteTransferStaging(transferId);
426
+ this.events.emit("stateChange", { ...inc.manifest });
427
+ }
428
+ break;
429
+ }
430
+ case "error": {
431
+ const code = ctrl.errorCode ?? "IO_ERROR";
432
+ const msg = ctrl.reason ?? "Remote transfer error";
433
+ const out = this.outgoing.get(transferId);
434
+ if (out) {
435
+ this.failOutgoingTransfer(out, new FileTransferError(msg, code, transferId));
436
+ }
437
+ break;
438
+ }
439
+ }
440
+ return;
441
+ }
442
+ if (frame.frameType === WireFrameType.TRANSFER_CHUNK) {
443
+ await this.handleIncomingChunk(peerDeviceId, transferId, frame);
444
+ }
445
+ }
446
+ async handleIncomingOffer(peerDeviceId, transferId, ctrl) {
447
+ const rawFileName = String(ctrl.fileName ?? "");
448
+ let safeFileName;
449
+ try {
450
+ safeFileName = sanitizeFileName(rawFileName);
451
+ }
452
+ catch (err) {
453
+ const msg = err instanceof Error ? err.message : "Invalid filename";
454
+ await this.sendControlFrame(peerDeviceId, transferId, {
455
+ action: "error",
456
+ errorCode: "PATH_TRAVERSAL_BLOCKED",
457
+ reason: msg,
458
+ });
459
+ this.events.emit("error", {
460
+ transferId,
461
+ error: new FileTransferError(msg, "PATH_TRAVERSAL_BLOCKED", transferId),
462
+ });
463
+ return;
464
+ }
465
+ const totalBytes = Number(ctrl.totalBytes ?? 0);
466
+ if (this.policy.maxFileSizeBytes !== undefined &&
467
+ totalBytes > this.policy.maxFileSizeBytes) {
468
+ const msg = `File size ${totalBytes} exceeds max policy limit ${this.policy.maxFileSizeBytes}`;
469
+ await this.sendControlFrame(peerDeviceId, transferId, {
470
+ action: "error",
471
+ errorCode: "FILE_TOO_LARGE",
472
+ reason: msg,
473
+ });
474
+ return;
475
+ }
476
+ const mimeType = String(ctrl.mimeType ?? "application/octet-stream");
477
+ if (this.policy.allowedMimeTypes &&
478
+ this.policy.allowedMimeTypes.length > 0 &&
479
+ !this.policy.allowedMimeTypes.includes(mimeType)) {
480
+ const msg = `MIME type '${mimeType}' is not allowed by receiver policy`;
481
+ await this.sendControlFrame(peerDeviceId, transferId, {
482
+ action: "error",
483
+ errorCode: "POLICY_REJECTED",
484
+ reason: msg,
485
+ });
486
+ return;
487
+ }
488
+ // Check existing staging chunks for resume support!
489
+ const existingWrittenChunks = await this.storage.listWrittenChunks(transferId);
490
+ const existingRuntime = this.incoming.get(transferId);
491
+ const receivedSet = new Set(existingWrittenChunks);
492
+ if (existingRuntime) {
493
+ for (const idx of existingRuntime.receivedChunks) {
494
+ receivedSet.add(idx);
495
+ }
496
+ }
497
+ const chunkSize = Number(ctrl.chunkSize ?? this.chunkSize);
498
+ const totalChunks = Number(ctrl.totalChunks ?? Math.max(1, Math.ceil(totalBytes / chunkSize)));
499
+ const now = Date.now();
500
+ const manifest = existingRuntime?.manifest ?? {
501
+ transferId,
502
+ peerDeviceId,
503
+ direction: "receive",
504
+ fileName: safeFileName,
505
+ mimeType,
506
+ totalBytes,
507
+ chunkSize,
508
+ totalChunks,
509
+ fileSha256: String(ctrl.fileSha256 ?? ""),
510
+ lastModified: Number(ctrl.lastModified ?? 0),
511
+ state: "offered",
512
+ completedChunks: Array.from(receivedSet).sort((a, b) => a - b),
513
+ transferredBytes: Math.min(totalBytes, receivedSet.size * chunkSize),
514
+ startedAt: now,
515
+ updatedAt: now,
516
+ };
517
+ const inc = existingRuntime ?? {
518
+ manifest,
519
+ receivedChunks: receivedSet,
520
+ paused: false,
521
+ cancelled: false,
522
+ };
523
+ inc.paused = false;
524
+ this.incoming.set(transferId, inc);
525
+ this.events.emit("incomingOffer", { ...manifest });
526
+ if (this.policy.onIncomingOffer) {
527
+ const allowed = await this.policy.onIncomingOffer({ ...manifest });
528
+ if (!allowed) {
529
+ await this.sendControlFrame(peerDeviceId, transferId, {
530
+ action: "error",
531
+ errorCode: "POLICY_REJECTED",
532
+ reason: "Transfer rejected by receiver policy callback",
533
+ });
534
+ return;
535
+ }
536
+ }
537
+ else if (!(this.policy.autoAccept ?? true)) {
538
+ return;
539
+ }
540
+ inc.manifest.state = "transferring";
541
+ inc.manifest.updatedAt = Date.now();
542
+ this.events.emit("stateChange", { ...inc.manifest });
543
+ await this.sendControlFrame(peerDeviceId, transferId, {
544
+ action: "accept",
545
+ receivedChunks: Array.from(inc.receivedChunks),
546
+ });
547
+ }
548
+ async pumpOutgoingChunks(rt) {
549
+ if (rt.sendingLoopPromise)
550
+ return rt.sendingLoopPromise;
551
+ rt.sendingLoopPromise = (async () => {
552
+ try {
553
+ const sess = this.options.client.getPeerSession(rt.manifest.peerDeviceId);
554
+ if (!sess || sess.getState() !== "connected") {
555
+ return;
556
+ }
557
+ const totalChunks = rt.manifest.totalChunks;
558
+ const chunkSize = rt.manifest.chunkSize;
559
+ for (let i = 0; i < totalChunks; i++) {
560
+ if (rt.cancelled || rt.paused)
561
+ return;
562
+ if (rt.ackedChunks.has(i))
563
+ continue;
564
+ const start = i * chunkSize;
565
+ const end = Math.min(rt.manifest.totalBytes, start + chunkSize);
566
+ const slice = await rt.source.readSlice(start, end);
567
+ if (rt.cancelled || rt.paused)
568
+ return;
569
+ const chunkHash = sha256HexSync(slice);
570
+ await sess.multiplexer.sendRawWireFrame(WireFrameType.TRANSFER_CHUNK, {
571
+ messageId: `tchk_${rt.manifest.transferId}_${i}`,
572
+ channel: "files",
573
+ timestamp: Date.now(),
574
+ transferId: rt.manifest.transferId,
575
+ chunkIndex: i,
576
+ totalChunks,
577
+ byteOffset: start,
578
+ chunkHash,
579
+ }, slice);
580
+ rt.ackedChunks.add(i);
581
+ rt.manifest.transferredBytes = Math.min(rt.manifest.totalBytes, rt.ackedChunks.size * chunkSize);
582
+ rt.manifest.updatedAt = Date.now();
583
+ const elapsedSec = Math.max(0.001, (Date.now() - rt.manifest.startedAt) / 1000);
584
+ this.events.emit("progress", {
585
+ transferId: rt.manifest.transferId,
586
+ peerDeviceId: rt.manifest.peerDeviceId,
587
+ direction: "send",
588
+ fileName: rt.manifest.fileName,
589
+ state: rt.manifest.state,
590
+ transferredBytes: rt.manifest.transferredBytes,
591
+ totalBytes: rt.manifest.totalBytes,
592
+ completedChunks: rt.ackedChunks.size,
593
+ totalChunks,
594
+ progressRatio: rt.manifest.totalBytes > 0
595
+ ? rt.manifest.transferredBytes / rt.manifest.totalBytes
596
+ : 1,
597
+ bytesPerSecond: Math.round(rt.manifest.transferredBytes / elapsedSec),
598
+ });
599
+ }
600
+ }
601
+ catch (err) {
602
+ const ftErr = err instanceof FileTransferError
603
+ ? err
604
+ : new FileTransferError(err instanceof Error ? err.message : String(err), "IO_ERROR", rt.manifest.transferId);
605
+ this.failOutgoingTransfer(rt, ftErr);
606
+ }
607
+ finally {
608
+ rt.sendingLoopPromise = null;
609
+ }
610
+ })();
611
+ return rt.sendingLoopPromise;
612
+ }
613
+ async handleIncomingChunk(peerDeviceId, transferId, frame) {
614
+ const inc = this.incoming.get(transferId);
615
+ if (!inc || inc.cancelled)
616
+ return;
617
+ const chunkIndex = frame.header.chunkIndex ?? 0;
618
+ const expectedChunkHash = frame.header.chunkHash;
619
+ if (expectedChunkHash) {
620
+ const actualChunkHash = sha256HexSync(frame.payload);
621
+ if (actualChunkHash !== expectedChunkHash) {
622
+ const err = new FileTransferError(`Chunk ${chunkIndex} SHA-256 mismatch`, "HASH_MISMATCH", transferId);
623
+ await this.failIncomingTransfer(inc, peerDeviceId, err);
624
+ return;
625
+ }
626
+ }
627
+ // Write chunk to persistent staging BEFORE acknowledging
628
+ try {
629
+ await this.storage.writeChunk(transferId, chunkIndex, frame.payload);
630
+ }
631
+ catch (storageErr) {
632
+ const code = storageErr instanceof DropspaceStorageError
633
+ ? storageErr.code
634
+ : "IO_ERROR";
635
+ const err = new FileTransferError(storageErr instanceof Error ? storageErr.message : String(storageErr), code, transferId);
636
+ await this.failIncomingTransfer(inc, peerDeviceId, err);
637
+ return;
638
+ }
639
+ inc.receivedChunks.add(chunkIndex);
640
+ inc.manifest.transferredBytes = Math.min(inc.manifest.totalBytes, inc.receivedChunks.size * inc.manifest.chunkSize);
641
+ inc.manifest.updatedAt = Date.now();
642
+ // Send persistent chunk_ack only AFTER writeChunk succeeded
643
+ await this.sendControlFrame(peerDeviceId, transferId, {
644
+ action: "chunk_ack",
645
+ chunkIndex,
646
+ }).catch(() => { });
647
+ const elapsedSec = Math.max(0.001, (Date.now() - inc.manifest.startedAt) / 1000);
648
+ this.events.emit("progress", {
649
+ transferId,
650
+ peerDeviceId,
651
+ direction: "receive",
652
+ fileName: inc.manifest.fileName,
653
+ state: inc.manifest.state,
654
+ transferredBytes: inc.manifest.transferredBytes,
655
+ totalBytes: inc.manifest.totalBytes,
656
+ completedChunks: inc.receivedChunks.size,
657
+ totalChunks: inc.manifest.totalChunks,
658
+ progressRatio: inc.manifest.totalBytes > 0
659
+ ? inc.manifest.transferredBytes / inc.manifest.totalBytes
660
+ : 1,
661
+ bytesPerSecond: Math.round(inc.manifest.transferredBytes / elapsedSec),
662
+ });
663
+ if (inc.receivedChunks.size === inc.manifest.totalChunks) {
664
+ this.syncCompletedChunks(inc.manifest, inc.receivedChunks);
665
+ await this.verifyAndFinalizeIncoming(inc, peerDeviceId);
666
+ }
667
+ }
668
+ async verifyAndFinalizeIncoming(inc, peerDeviceId) {
669
+ const transferId = inc.manifest.transferId;
670
+ inc.manifest.state = "verifying";
671
+ this.events.emit("stateChange", { ...inc.manifest });
672
+ // Bounded-memory streaming SHA-256 verification across staged chunks
673
+ const hasher = new StreamingSha256();
674
+ let sinceYield = 0;
675
+ for (let i = 0; i < inc.manifest.totalChunks; i++) {
676
+ const chunk = await this.storage.readChunk(transferId, i);
677
+ if (!chunk) {
678
+ const err = new FileTransferError(`Missing staged chunk ${i} during final verification`, "IO_ERROR", transferId);
679
+ await this.failIncomingTransfer(inc, peerDeviceId, err);
680
+ return;
681
+ }
682
+ hasher.update(chunk);
683
+ sinceYield += chunk.byteLength;
684
+ if (sinceYield >= 16 * 1024 * 1024 && i + 1 < inc.manifest.totalChunks) {
685
+ sinceYield = 0;
686
+ await new Promise((resolve) => setTimeout(resolve, 0));
687
+ }
688
+ }
689
+ const computedSha256 = hasher.digestHex();
690
+ if (computedSha256 !== inc.manifest.fileSha256) {
691
+ const err = new FileTransferError(`Full file SHA-256 integrity mismatch (expected ${inc.manifest.fileSha256}, got ${computedSha256})`, "HASH_MISMATCH", transferId);
692
+ await this.failIncomingTransfer(inc, peerDeviceId, err);
693
+ return;
694
+ }
695
+ try {
696
+ const { finalKey } = await this.storage.finalizeTransfer(transferId, inc.manifest.fileName, inc.manifest.totalChunks, { allowOverwrite: this.policy.allowOverwrite });
697
+ inc.manifest.state = "completed";
698
+ inc.manifest.finalStorageKey = finalKey;
699
+ inc.manifest.transferredBytes = inc.manifest.totalBytes;
700
+ inc.manifest.updatedAt = Date.now();
701
+ await this.sendControlFrame(peerDeviceId, transferId, {
702
+ action: "complete",
703
+ }).catch(() => { });
704
+ this.events.emit("stateChange", { ...inc.manifest });
705
+ this.events.emit("completed", {
706
+ manifest: { ...inc.manifest },
707
+ finalStorageKey: finalKey,
708
+ });
709
+ }
710
+ catch (storageErr) {
711
+ const code = storageErr instanceof DropspaceStorageError
712
+ ? storageErr.code
713
+ : "IO_ERROR";
714
+ const err = new FileTransferError(storageErr instanceof Error ? storageErr.message : String(storageErr), code, transferId);
715
+ await this.failIncomingTransfer(inc, peerDeviceId, err);
716
+ }
717
+ }
718
+ failOutgoingTransfer(rt, err) {
719
+ rt.cancelled = true;
720
+ rt.manifest.state = "failed";
721
+ rt.manifest.error = { code: err.code, message: err.message };
722
+ rt.manifest.updatedAt = Date.now();
723
+ this.events.emit("stateChange", { ...rt.manifest });
724
+ this.events.emit("error", { transferId: rt.manifest.transferId, error: err });
725
+ void this.sendControlFrame(rt.manifest.peerDeviceId, rt.manifest.transferId, {
726
+ action: "error",
727
+ errorCode: err.code,
728
+ reason: err.message,
729
+ }).catch(() => { });
730
+ for (const w of rt.completionWaiters.splice(0)) {
731
+ w.reject(err);
732
+ }
733
+ }
734
+ async failIncomingTransfer(inc, peerDeviceId, err) {
735
+ inc.manifest.state = "failed";
736
+ inc.manifest.error = { code: err.code, message: err.message };
737
+ inc.manifest.updatedAt = Date.now();
738
+ this.events.emit("stateChange", { ...inc.manifest });
739
+ this.events.emit("error", { transferId: inc.manifest.transferId, error: err });
740
+ await this.sendControlFrame(peerDeviceId, inc.manifest.transferId, {
741
+ action: "error",
742
+ errorCode: err.code,
743
+ reason: err.message,
744
+ }).catch(() => { });
745
+ }
746
+ close() {
747
+ this.unsubscribeClient();
748
+ this.events.removeAllListeners();
749
+ }
750
+ }
751
+ //# sourceMappingURL=index.js.map