most-box 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,1073 @@
1
+ /**
2
+ * MostBoxEngine - Core P2P Engine
3
+ * Platform-agnostic engine for P2P file sharing using Hyperswarm/Hyperdrive
4
+ */
5
+
6
+ import EventEmitter from 'eventemitter3'
7
+ import Hyperswarm from 'hyperswarm'
8
+ import Corestore from 'corestore'
9
+ import Hyperdrive from 'hyperdrive'
10
+ import b4a from 'b4a'
11
+ import { CID } from 'multiformats/cid'
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+
15
+ import { calculateCid, parseMostLink } from './core/cid.js'
16
+ import { sanitizeFilename, validateAndSanitizePath, validateFileSize, checkDirectoryWritable, formatFileSize } from './utils/security.js'
17
+ import { ValidationError, PathSecurityError, FileSizeError, PeerNotFoundError, IntegrityError, PermissionError, EngineNotInitializedError } from './utils/errors.js'
18
+ import { GLOBAL_SHARED_SEED_STRING, MAX_FILE_SIZE, CONNECTION_TIMEOUT, DOWNLOAD_TIMEOUT, SWARM_BOOTSTRAP } from './config.js'
19
+
20
+ export class MostBoxEngine extends EventEmitter {
21
+ #store = null
22
+ #swarm = null
23
+ #drives = new Map()
24
+ #publishedFiles = []
25
+ #trashFiles = []
26
+ #initialized = false
27
+ #options = null
28
+ #activeDownloads = new Map() // taskId -> { aborted, readStream, writeStream }
29
+
30
+ /**
31
+ * Create a new MostBoxEngine instance
32
+ * @param {object} options - Configuration options
33
+ * @param {string} options.dataPath - Path to store P2P data (required)
34
+ * @param {string} [options.downloadPath] - Default download path (optional, defaults to dataPath/downloads)
35
+ * @param {number} [options.maxFileSize] - Maximum file size in bytes (default: 100GB)
36
+ */
37
+ constructor(options) {
38
+ super()
39
+
40
+ if (!options || !options.dataPath) {
41
+ throw new Error('dataPath is required')
42
+ }
43
+
44
+ this.#options = {
45
+ dataPath: options.dataPath,
46
+ downloadPath: options.downloadPath || path.join(options.dataPath, 'downloads'),
47
+ maxFileSize: options.maxFileSize || MAX_FILE_SIZE
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Initialize the engine - must be called before other methods
53
+ */
54
+ async start() {
55
+ if (this.#initialized) {
56
+ return
57
+ }
58
+
59
+ const { dataPath } = this.#options
60
+
61
+ console.log(`[MostBox] Initializing engine...`)
62
+ console.log(`[MostBox] Storage path: ${dataPath}`)
63
+
64
+ // Create storage directory if not exists
65
+ if (!fs.existsSync(dataPath)) {
66
+ fs.mkdirSync(dataPath, { recursive: true })
67
+ }
68
+
69
+ // Initialize Corestore with global shared seed
70
+ const GLOBAL_SHARED_SEED = b4a.alloc(32).fill(GLOBAL_SHARED_SEED_STRING)
71
+ this.#store = new Corestore(dataPath, { primaryKey: GLOBAL_SHARED_SEED, unsafe: true })
72
+
73
+ try {
74
+ await this.#store.ready()
75
+ console.log(`[MostBox] Corestore ready`)
76
+ } catch (err) {
77
+ if (err.message && err.message.includes('Another corestore is stored here')) {
78
+ console.log(`[MostBox] Resetting corrupt storage...`)
79
+ fs.rmSync(dataPath, { recursive: true, force: true })
80
+ fs.mkdirSync(dataPath, { recursive: true })
81
+ this.#store = new Corestore(dataPath, { primaryKey: GLOBAL_SHARED_SEED, unsafe: true })
82
+ await this.#store.ready()
83
+ console.log(`[MostBox] Corestore reset and ready`)
84
+ } else if (err.message && err.message.includes('Invalid device file')) {
85
+ throw new Error(`存储文件损坏,请关闭其他访问 ${dataPath} 的程序后重试`)
86
+ } else if (err.message && err.message.includes('File descriptor could not be locked')) {
87
+ throw new Error(`存储文件被锁定,请关闭其他访问 ${dataPath} 的程序后重试`)
88
+ } else {
89
+ throw err
90
+ }
91
+ }
92
+
93
+ // Initialize Hyperswarm with NAT traversal enabled
94
+ console.log(`[MostBox] Initializing Hyperswarm...`)
95
+ this.#swarm = new Hyperswarm({
96
+ // Connection settings for better stability
97
+ maxPeers: 64,
98
+ // DHT bootstrap nodes (same as Keet.io/HyperDHT)
99
+ bootstrap: SWARM_BOOTSTRAP,
100
+ // Enable NAT traversal (hole punching)
101
+ // firewall function: allow all connections (default behavior)
102
+ firewall: () => false,
103
+ // Connection keep-alive timeout (5 seconds)
104
+ connectionKeepAlive: 5000,
105
+ // Random punch interval for NAT traversal (20 seconds)
106
+ randomPunchInterval: 20000,
107
+ // Increase timeouts for unstable networks
108
+ handshakeTimeout: CONNECTION_TIMEOUT
109
+ })
110
+
111
+ // Handle swarm-level errors
112
+ this.#swarm.on('error', (err) => {
113
+ // Silently handle SSL/network errors - they're non-critical for DHT discovery
114
+ if (err.code === 'SSL_ERROR' || err.message?.includes('handshake') || err.message?.includes('ECONNRESET')) {
115
+ console.warn('[MostBox] Network warning (non-critical):', err.message)
116
+ return
117
+ }
118
+ console.error('[MostBox] Swarm error:', err.message)
119
+ this.emit('error', err)
120
+ })
121
+
122
+ // Replicate store on new connections
123
+ this.#swarm.on('connection', (conn, info) => {
124
+ console.log(`[MostBox] New peer connection established`)
125
+ // Handle connection errors gracefully
126
+ conn.on('error', (err) => {
127
+ if (err.code === 'SSL_ERROR' || err.message?.includes('handshake')) {
128
+ console.warn('[MostBox] Connection warning:', err.message)
129
+ return
130
+ }
131
+ console.error('[MostBox] Connection error:', err.message)
132
+ })
133
+
134
+ this.#store.replicate(conn)
135
+ this.emit('connection', conn)
136
+ })
137
+
138
+ // Load published files metadata
139
+ this.#publishedFiles = this.#loadPublishedMetadata()
140
+ console.log(`[MostBox] Loaded ${this.#publishedFiles.length} published files`)
141
+
142
+ // Load trash files metadata
143
+ this.#trashFiles = this.#loadTrashMetadata()
144
+ console.log(`[MostBox] Loaded ${this.#trashFiles.length} trash files`)
145
+
146
+ this.#initialized = true
147
+ console.log(`[MostBox] Engine initialized successfully`)
148
+ this.emit('ready')
149
+
150
+ return this
151
+ }
152
+
153
+ /**
154
+ * Stop the engine and cleanup resources
155
+ */
156
+ async stop() {
157
+ if (!this.#initialized) {
158
+ return
159
+ }
160
+
161
+ // Close all drives
162
+ for (const drive of this.#drives.values()) {
163
+ await drive.close()
164
+ }
165
+ this.#drives.clear()
166
+
167
+ // Destroy swarm
168
+ if (this.#swarm) {
169
+ await this.#swarm.destroy()
170
+ this.#swarm = null
171
+ }
172
+
173
+ // Close store
174
+ if (this.#store) {
175
+ await this.#store.close()
176
+ this.#store = null
177
+ }
178
+
179
+ this.#initialized = false
180
+ this.emit('stopped')
181
+ }
182
+
183
+ /**
184
+ * Get the node's public key
185
+ * @returns {string} Node ID as hex string
186
+ */
187
+ getNodeId() {
188
+ this.#ensureInitialized()
189
+ return b4a.toString(this.#swarm.keyPair.publicKey, 'hex')
190
+ }
191
+
192
+ /**
193
+ * Get current network status
194
+ * @returns {{ peers: number, status: string }}
195
+ */
196
+ getNetworkStatus() {
197
+ this.#ensureInitialized()
198
+ const connections = this.#swarm.connections.size
199
+ return {
200
+ peers: connections,
201
+ status: connections > 0 ? 'connected' : 'waiting'
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Publish content to the P2P network
207
+ * @param {string|Buffer} content - File path (string) or content (Buffer)
208
+ * @param {string} [fileName] - Name for the file (required for Buffer input)
209
+ * @returns {Promise<{ cid: string, link: string, fileName: string }>}
210
+ */
211
+ async publishFile(content, fileName) {
212
+ this.#ensureInitialized()
213
+
214
+ let cleanPath = null
215
+ let safeFileName
216
+ let fileSize
217
+
218
+ if (Buffer.isBuffer(content)) {
219
+ if (!fileName) {
220
+ throw new Error('fileName is required when publishing Buffer content')
221
+ }
222
+ safeFileName = sanitizeFilename(fileName)
223
+ fileSize = content.length
224
+ } else {
225
+ cleanPath = content
226
+ const pathValidation = validateAndSanitizePath(cleanPath)
227
+ if (pathValidation.error) {
228
+ throw new PathSecurityError(pathValidation.error)
229
+ }
230
+ cleanPath = pathValidation.cleanPath
231
+
232
+ const sizeValidation = await validateFileSize(cleanPath, this.#options.maxFileSize)
233
+ if (!sizeValidation.valid) {
234
+ throw new FileSizeError(sizeValidation.error, sizeValidation.size)
235
+ }
236
+ fileSize = sizeValidation.size
237
+
238
+ safeFileName = sanitizeFilename(fileName || path.basename(cleanPath))
239
+ }
240
+
241
+ if (fileSize > this.#options.maxFileSize) {
242
+ const maxGB = Math.round(this.#options.maxFileSize / (1024 * 1024 * 1024))
243
+ throw new FileSizeError(`File size exceeds limit of ${maxGB} GB`, fileSize)
244
+ }
245
+
246
+ this.emit('publish:progress', { stage: 'calculating-cid', file: safeFileName })
247
+
248
+ const { cid: rootCid } = await calculateCid(content)
249
+ const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
250
+ const cidString = rootCid.toString()
251
+
252
+ const name = `drive-${hashHex}`
253
+ let drive = this.#drives.get(name)
254
+
255
+ if (!drive) {
256
+ drive = new Hyperdrive(this.#store.namespace(name))
257
+ await drive.ready()
258
+ this.#drives.set(name, drive)
259
+
260
+ const discovery = this.#swarm.join(drive.discoveryKey, { server: true, client: false })
261
+ await discovery.flushed()
262
+ }
263
+
264
+ this.emit('publish:progress', { stage: 'uploading', file: safeFileName })
265
+
266
+ const ws = drive.createWriteStream(safeFileName)
267
+
268
+ if (Buffer.isBuffer(content)) {
269
+ // Stream buffer in chunks to avoid exceeding Hyperdrive block size limit
270
+ const CHUNK_SIZE = 64 * 1024 // 64KB chunks
271
+ let offset = 0
272
+
273
+ const waitForDrain = () => new Promise(resolve => ws.once('drain', resolve))
274
+
275
+ while (offset < content.length) {
276
+ const chunk = content.slice(offset, offset + CHUNK_SIZE)
277
+ const canContinue = ws.write(chunk)
278
+ offset += chunk.length
279
+
280
+ if (!canContinue && offset < content.length) {
281
+ await waitForDrain()
282
+ }
283
+ }
284
+
285
+ ws.end()
286
+ await new Promise((resolve, reject) => {
287
+ ws.on('finish', resolve)
288
+ ws.on('error', reject)
289
+ })
290
+ } else {
291
+ const rs = fs.createReadStream(cleanPath)
292
+ await new Promise((resolve, reject) => {
293
+ rs.pipe(ws)
294
+ ws.on('finish', resolve)
295
+ ws.on('error', reject)
296
+ rs.on('error', reject)
297
+ })
298
+ }
299
+
300
+ const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
301
+ if (existingIndex !== -1) {
302
+ const existing = this.#publishedFiles[existingIndex]
303
+ // Same content already exists - return "already exists" regardless of filename
304
+ return {
305
+ cid: cidString,
306
+ link: `most://${cidString}`,
307
+ fileName: existing.fileName,
308
+ alreadyExists: true
309
+ }
310
+ } else {
311
+ this.#publishedFiles.push({
312
+ fileName: safeFileName,
313
+ cid: cidString,
314
+ publishedAt: new Date().toISOString(),
315
+ starred: false
316
+ })
317
+ }
318
+ this.#savePublishedMetadata()
319
+
320
+ const result = {
321
+ cid: cidString,
322
+ link: `most://${cidString}`,
323
+ fileName: safeFileName
324
+ }
325
+
326
+ this.emit('publish:success', result)
327
+ return result
328
+ }
329
+
330
+ /**
331
+ * Download a file from the P2P network
332
+ * @param {string} link - most:// link
333
+ * @param {string} [taskId] - Task ID for cancellation
334
+ * @returns {Promise<{ taskId: string, fileName: string, savedPath: string, alreadyExists?: boolean }>}
335
+ */
336
+ async downloadFile(link, taskId = null) {
337
+ this.#ensureInitialized()
338
+
339
+ // Generate taskId if not provided
340
+ taskId = taskId || `dl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
341
+
342
+ console.log(`[MostBox] Starting download for link: ${link} (taskId: ${taskId})`)
343
+
344
+ // Register in active downloads
345
+ const taskState = { aborted: false, readStream: null, writeStream: null }
346
+ this.#activeDownloads.set(taskId, taskState)
347
+
348
+ try {
349
+ // Parse link
350
+ const parsed = parseMostLink(link)
351
+ if (parsed.error) {
352
+ throw new ValidationError(parsed.error)
353
+ }
354
+ const cidString = parsed.cid
355
+ console.log(`[MostBox] Parsed CID: ${cidString}`)
356
+
357
+ // Check if file already exists in published files
358
+ const existingFile = this.#publishedFiles.find(f => f.cid === cidString)
359
+ if (existingFile) {
360
+ console.log(`[MostBox] File already exists: ${existingFile.fileName}`)
361
+ return {
362
+ taskId,
363
+ fileName: existingFile.fileName,
364
+ alreadyExists: true
365
+ }
366
+ }
367
+
368
+ // Parse CID
369
+ const parsedCid = CID.parse(cidString)
370
+ const hashBytes = parsedCid.multihash.digest
371
+ const hashHex = b4a.toString(hashBytes, 'hex')
372
+
373
+ // Check cancellation
374
+ if (taskState.aborted) throw new Error('Download cancelled')
375
+
376
+ // Get/Create drive
377
+ const name = `drive-${hashHex}`
378
+ let drive = this.#drives.get(name)
379
+
380
+ if (!drive) {
381
+ console.log(`[MostBox] Creating new drive: ${name}`)
382
+ drive = new Hyperdrive(this.#store.namespace(name))
383
+ await drive.ready()
384
+ this.#drives.set(name, drive)
385
+
386
+ this.emit('download:status', { taskId, status: 'connecting' })
387
+
388
+ console.log(`[MostBox] Joining swarm for drive discovery...`)
389
+ // Join as both server and client to allow self-downloads
390
+ await this.#swarm.join(drive.discoveryKey, { server: true, client: true }).flushed()
391
+ console.log(`[MostBox] Swarm join flushed`)
392
+ } else {
393
+ console.log(`[MostBox] Using existing drive: ${name}`)
394
+ }
395
+
396
+ // Check cancellation
397
+ if (taskState.aborted) throw new Error('Download cancelled')
398
+
399
+ this.emit('download:status', { taskId, status: 'finding-peers' })
400
+
401
+ // Wait for peers and data to sync
402
+ console.log(`[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT/1000}s)...`)
403
+ const entries = await this.#waitForDriveContent(drive, DOWNLOAD_TIMEOUT, taskId, taskState)
404
+
405
+ if (entries.length === 0) {
406
+ console.log(`[MostBox] No entries found after timeout`)
407
+
408
+ // 提供更详细的错误信息
409
+ const peerCount = this.#swarm.connections.size
410
+ let errorMessage = 'No files found in drive. '
411
+
412
+ if (peerCount === 0) {
413
+ errorMessage += 'Could not connect to any peers. This may be due to:\n'
414
+ errorMessage += '1. Network firewall blocking P2P connections\n'
415
+ errorMessage += '2. DHT bootstrap nodes unreachable\n'
416
+ errorMessage += '3. NAT traversal failed (try port forwarding)\n'
417
+ errorMessage += '4. No peers are currently sharing this file'
418
+ } else {
419
+ errorMessage += `Connected to ${peerCount} peers but no file data was found. This may be due to:\n`
420
+ errorMessage += '1. Publisher node offline\n'
421
+ errorMessage += '2. File may have been removed by publisher\n'
422
+ errorMessage += '3. File link may be invalid or corrupted'
423
+ }
424
+
425
+ throw new PeerNotFoundError(errorMessage)
426
+ }
427
+
428
+ // Check cancellation
429
+ if (taskState.aborted) throw new Error('Download cancelled')
430
+
431
+ console.log(`[MostBox] Found ${entries.length} entries, starting download...`)
432
+
433
+ // Save to storage directory (not Downloads folder)
434
+ const targetDir = this.#options.dataPath
435
+
436
+ // Check storage directory
437
+ const writableCheck = await checkDirectoryWritable(targetDir)
438
+ if (!writableCheck.writable) {
439
+ throw new PermissionError(writableCheck.error)
440
+ }
441
+
442
+ // Download files
443
+ for (const entry of entries) {
444
+ const sanitizedFileName = sanitizeFilename(entry.key.replace(/^[\/\\]/, ''))
445
+
446
+ // Get file size
447
+ let totalBytes = 0
448
+ try {
449
+ const stat = await drive.entry(entry.key)
450
+ if (stat && stat.value && stat.value.blob) {
451
+ totalBytes = stat.value.blob.byteLength || 0
452
+ }
453
+ } catch {
454
+ // Ignore
455
+ }
456
+
457
+ const savePath = path.join(targetDir, sanitizedFileName)
458
+
459
+ this.emit('download:status', {
460
+ taskId,
461
+ status: 'downloading',
462
+ file: sanitizedFileName,
463
+ size: totalBytes ? formatFileSize(totalBytes) : null
464
+ })
465
+
466
+ // Download with progress
467
+ const rs = drive.createReadStream(entry.key)
468
+ const ws = fs.createWriteStream(savePath)
469
+
470
+ taskState.readStream = rs
471
+ taskState.writeStream = ws
472
+
473
+ let loadedBytes = 0
474
+ let lastProgressUpdate = 0
475
+
476
+ await new Promise((resolve, reject) => {
477
+ rs.on('data', (chunk) => {
478
+ // Check cancellation
479
+ if (taskState.aborted) {
480
+ rs.destroy()
481
+ ws.destroy()
482
+ reject(new Error('Download cancelled'))
483
+ return
484
+ }
485
+ loadedBytes += chunk.length
486
+ const now = Date.now()
487
+ if (totalBytes > 0 && now - lastProgressUpdate > 500) {
488
+ lastProgressUpdate = now
489
+ const percent = Math.round((loadedBytes / totalBytes) * 100)
490
+ this.emit('download:progress', { taskId, loaded: loadedBytes, total: totalBytes, percent })
491
+ }
492
+ })
493
+
494
+ rs.pipe(ws)
495
+ ws.on('finish', resolve)
496
+ ws.on('error', reject)
497
+ rs.on('error', reject)
498
+ })
499
+
500
+ // Check cancellation before verification
501
+ if (taskState.aborted) throw new Error('Download cancelled')
502
+
503
+ // Verify integrity
504
+ this.emit('download:status', { taskId, status: 'verifying' })
505
+
506
+ const { cid: downloadedCid } = await calculateCid(savePath)
507
+ const expectedHash = b4a.toString(parsedCid.multihash.digest, 'hex')
508
+ const actualHash = b4a.toString(downloadedCid.multihash.digest, 'hex')
509
+
510
+ if (expectedHash !== actualHash) {
511
+ fs.unlinkSync(savePath)
512
+ throw new IntegrityError(`File content CID mismatch. File may be corrupted or tampered.`)
513
+ }
514
+
515
+ const result = {
516
+ taskId,
517
+ fileName: sanitizedFileName,
518
+ savedPath: savePath
519
+ }
520
+
521
+ // 将下载的文件添加到已发布文件列表
522
+ const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
523
+ if (existingIndex !== -1) {
524
+ const existing = this.#publishedFiles[existingIndex]
525
+ if (existing.fileName !== sanitizedFileName) {
526
+ throw new Error(`文件已存在: ${existing.fileName}`)
527
+ }
528
+ existing.publishedAt = new Date().toISOString()
529
+ } else {
530
+ this.#publishedFiles.push({
531
+ fileName: sanitizedFileName,
532
+ cid: cidString,
533
+ publishedAt: new Date().toISOString(),
534
+ starred: false
535
+ })
536
+ }
537
+ this.#savePublishedMetadata()
538
+
539
+ this.emit('download:success', result)
540
+ return result
541
+ }
542
+ } finally {
543
+ this.#activeDownloads.delete(taskId)
544
+ }
545
+ }
546
+
547
+ /**
548
+ * List all published files
549
+ * @param {object} [options] - Filter options
550
+ * @param {boolean} [options.starred] - Filter by starred status
551
+ * @returns {Array<{ fileName: string, cid: string, link: string, publishedAt: string, starred: boolean }>}
552
+ */
553
+ listPublishedFiles(options = {}) {
554
+ this.#ensureInitialized()
555
+ let files = this.#publishedFiles
556
+
557
+ if (options.starred === true) {
558
+ files = files.filter(f => f.starred === true)
559
+ }
560
+
561
+ return files.map(f => ({
562
+ fileName: f.fileName,
563
+ cid: f.cid,
564
+ link: `most://${f.cid}`,
565
+ publishedAt: f.publishedAt,
566
+ starred: f.starred || false
567
+ }))
568
+ }
569
+
570
+ /**
571
+ * Toggle starred status of a file
572
+ * @param {string} cid - CID of the file
573
+ * @returns {object} Updated file info
574
+ */
575
+ toggleStarred(cid) {
576
+ this.#ensureInitialized()
577
+ const index = this.#publishedFiles.findIndex(f => f.cid === cid)
578
+ if (index === -1) {
579
+ throw new Error('File not found')
580
+ }
581
+ this.#publishedFiles[index].starred = !this.#publishedFiles[index].starred
582
+ this.#savePublishedMetadata()
583
+ return {
584
+ cid,
585
+ starred: this.#publishedFiles[index].starred
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Delete a published file - moves to trash instead of permanent deletion
591
+ * @param {string} cid - CID of the file to delete
592
+ * @returns {Promise<Array>} Updated list of published files
593
+ */
594
+ async deletePublishedFile(cid) {
595
+ this.#ensureInitialized()
596
+ const index = this.#publishedFiles.findIndex(f => f.cid === cid)
597
+ if (index !== -1) {
598
+ const fileRecord = this.#publishedFiles[index]
599
+
600
+ // Move to trash instead of permanent deletion
601
+ this.#trashFiles.push({
602
+ fileName: fileRecord.fileName,
603
+ cid: fileRecord.cid,
604
+ publishedAt: fileRecord.publishedAt,
605
+ starred: fileRecord.starred || false,
606
+ deletedAt: new Date().toISOString()
607
+ })
608
+ this.#saveTrashMetadata()
609
+
610
+ // Remove from published files
611
+ this.#publishedFiles.splice(index, 1)
612
+ this.#savePublishedMetadata()
613
+ }
614
+ return this.listPublishedFiles()
615
+ }
616
+
617
+ /**
618
+ * List all files in trash
619
+ * @returns {Array} Trash files
620
+ */
621
+ listTrashFiles() {
622
+ this.#ensureInitialized()
623
+ return this.#trashFiles.map(f => ({
624
+ fileName: f.fileName,
625
+ cid: f.cid,
626
+ link: `most://${f.cid}`,
627
+ publishedAt: f.publishedAt,
628
+ starred: f.starred || false,
629
+ deletedAt: f.deletedAt
630
+ }))
631
+ }
632
+
633
+ /**
634
+ * Restore a file from trash
635
+ * @param {string} cid - CID of the file to restore
636
+ * @returns {Array} Updated list of published files
637
+ */
638
+ restoreTrashFile(cid) {
639
+ this.#ensureInitialized()
640
+ const index = this.#trashFiles.findIndex(f => f.cid === cid)
641
+ if (index === -1) {
642
+ throw new Error('File not found in trash')
643
+ }
644
+
645
+ const fileRecord = this.#trashFiles[index]
646
+
647
+ // Restore to published files
648
+ this.#publishedFiles.push({
649
+ fileName: fileRecord.fileName,
650
+ cid: fileRecord.cid,
651
+ publishedAt: fileRecord.publishedAt,
652
+ starred: fileRecord.starred || false
653
+ })
654
+ this.#savePublishedMetadata()
655
+
656
+ // Remove from trash
657
+ this.#trashFiles.splice(index, 1)
658
+ this.#saveTrashMetadata()
659
+
660
+ return this.listPublishedFiles()
661
+ }
662
+
663
+ /**
664
+ * Permanently delete a file from trash
665
+ * @param {string} cid - CID of the file to permanently delete
666
+ * @returns {Promise<Array>} Updated trash list
667
+ */
668
+ async permanentDeleteTrashFile(cid) {
669
+ this.#ensureInitialized()
670
+ const index = this.#trashFiles.findIndex(f => f.cid === cid)
671
+ if (index !== -1) {
672
+ const fileRecord = this.#trashFiles[index]
673
+
674
+ // Reconstruct drive name from CID
675
+ const parsedCid = CID.parse(cid)
676
+ const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
677
+ const driveName = `drive-${hashHex}`
678
+
679
+ // Delete file from Hyperdrive and cleanup drive
680
+ const drive = this.#drives.get(driveName)
681
+ if (drive) {
682
+ try {
683
+ await drive.del(fileRecord.fileName)
684
+ } catch (err) {
685
+ // File may not exist in drive, continue with cleanup
686
+ }
687
+
688
+ // Leave swarm for this drive
689
+ await this.#swarm.leave(drive.discoveryKey)
690
+
691
+ // Close and remove drive
692
+ await drive.close()
693
+ this.#drives.delete(driveName)
694
+ }
695
+
696
+ // Remove from trash
697
+ this.#trashFiles.splice(index, 1)
698
+ this.#saveTrashMetadata()
699
+ }
700
+ return this.listTrashFiles()
701
+ }
702
+
703
+ /**
704
+ * Empty the trash - permanently delete all trash files
705
+ * @returns {Promise<Array>} Empty trash list
706
+ */
707
+ async emptyTrash() {
708
+ this.#ensureInitialized()
709
+
710
+ for (const fileRecord of this.#trashFiles) {
711
+ // Reconstruct drive name from CID
712
+ const parsedCid = CID.parse(fileRecord.cid)
713
+ const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
714
+ const driveName = `drive-${hashHex}`
715
+
716
+ // Delete file from Hyperdrive and cleanup drive
717
+ const drive = this.#drives.get(driveName)
718
+ if (drive) {
719
+ try {
720
+ await drive.del(fileRecord.fileName)
721
+ } catch (err) {
722
+ // File may not exist in drive, continue with cleanup
723
+ }
724
+
725
+ // Leave swarm for this drive
726
+ this.#swarm.leave(drive.discoveryKey)
727
+
728
+ // Close and remove drive
729
+ await drive.close()
730
+ this.#drives.delete(driveName)
731
+ }
732
+ }
733
+
734
+ // Clear trash
735
+ this.#trashFiles = []
736
+ this.#saveTrashMetadata()
737
+
738
+ return []
739
+ }
740
+
741
+ /**
742
+ * Get storage statistics
743
+ * @returns {Promise<{ total: number, used: number, free: number, fileCount: number, trashCount: number }>}
744
+ */
745
+ async getStorageStats() {
746
+ this.#ensureInitialized()
747
+
748
+ let totalSize = 0
749
+ let freeSize = 0
750
+ const { dataPath } = this.#options
751
+
752
+ try {
753
+ const stats = fs.statfsSync(dataPath)
754
+ totalSize = stats.bsize * stats.blocks
755
+ freeSize = stats.bsize * stats.bfree
756
+ } catch (err) {
757
+ // Fallback if statfs is not available
758
+ try {
759
+ const stats = fs.statSync(dataPath)
760
+ totalSize = 0
761
+ freeSize = 0
762
+ } catch {
763
+ totalSize = 0
764
+ freeSize = 0
765
+ }
766
+ }
767
+
768
+ // Calculate used space by files
769
+ let usedSize = 0
770
+ const calculateDirSize = (dirPath) => {
771
+ try {
772
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
773
+ for (const entry of entries) {
774
+ const fullPath = path.join(dirPath, entry.name)
775
+ if (entry.isDirectory()) {
776
+ if (entry.name !== 'db') {
777
+ calculateDirSize(fullPath)
778
+ }
779
+ } else {
780
+ try {
781
+ const stat = fs.statSync(fullPath)
782
+ usedSize += stat.size
783
+ } catch {
784
+ // Skip files we can't access
785
+ }
786
+ }
787
+ }
788
+ } catch {
789
+ // Skip directories we can't access
790
+ }
791
+ }
792
+
793
+ calculateDirSize(dataPath)
794
+
795
+ return {
796
+ total: totalSize,
797
+ used: usedSize,
798
+ free: freeSize,
799
+ fileCount: this.#publishedFiles.length,
800
+ trashCount: this.#trashFiles.length
801
+ }
802
+ }
803
+
804
+ /**
805
+ * Move/rename a published file (changes path without re-uploading)
806
+ * @param {string} cid - CID of the file to move
807
+ * @param {string} newFileName - New file path
808
+ * @returns {object} Updated file info
809
+ */
810
+ moveFile(cid, newFileName) {
811
+ this.#ensureInitialized()
812
+ const index = this.#publishedFiles.findIndex(f => f.cid === cid)
813
+ if (index === -1) {
814
+ throw new Error('File not found')
815
+ }
816
+ const safeFileName = sanitizeFilename(newFileName)
817
+ this.#publishedFiles[index].fileName = safeFileName
818
+ this.#publishedFiles[index].publishedAt = new Date().toISOString()
819
+ this.#savePublishedMetadata()
820
+ return {
821
+ cid,
822
+ fileName: safeFileName,
823
+ link: `most://${cid}`
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Rename a folder (renames all files within the folder)
829
+ * @param {string} oldPath - Current folder path
830
+ * @param {string} newPath - New folder path
831
+ * @returns {object} Updated files info
832
+ */
833
+ renameFolder(oldPath, newPath) {
834
+ this.#ensureInitialized()
835
+ const prefix = oldPath + '/'
836
+ const updatedFiles = []
837
+
838
+ for (const file of this.#publishedFiles) {
839
+ if (file.fileName.startsWith(prefix)) {
840
+ const newFileName = newPath + file.fileName.substring(prefix.length)
841
+ file.fileName = sanitizeFilename(newFileName)
842
+ file.publishedAt = new Date().toISOString()
843
+ updatedFiles.push({
844
+ cid: file.cid,
845
+ fileName: file.fileName,
846
+ link: `most://${file.cid}`
847
+ })
848
+ }
849
+ }
850
+
851
+ if (updatedFiles.length > 0) {
852
+ this.#savePublishedMetadata()
853
+ }
854
+
855
+ return { files: updatedFiles }
856
+ }
857
+
858
+ /**
859
+ * Cancel an active download
860
+ * @param {string} taskId - The task ID of the download to cancel
861
+ */
862
+ cancelDownload(taskId) {
863
+ const task = this.#activeDownloads.get(taskId)
864
+ if (task) {
865
+ task.aborted = true
866
+ if (task.readStream) task.readStream.destroy()
867
+ if (task.writeStream) task.writeStream.destroy()
868
+ }
869
+ }
870
+
871
+ getPublishedFiles() {
872
+ return this.#publishedFiles
873
+ }
874
+
875
+ // --- Private methods ---
876
+
877
+ #ensureInitialized() {
878
+ if (!this.#initialized) {
879
+ throw new EngineNotInitializedError()
880
+ }
881
+ }
882
+
883
+ #getMetadataPath() {
884
+ return path.join(this.#options.dataPath, 'published-files.json')
885
+ }
886
+
887
+ #getTrashMetadataPath() {
888
+ return path.join(this.#options.dataPath, 'trash-files.json')
889
+ }
890
+
891
+ #loadPublishedMetadata() {
892
+ try {
893
+ const metadataPath = this.#getMetadataPath()
894
+ if (fs.existsSync(metadataPath)) {
895
+ const data = fs.readFileSync(metadataPath, 'utf-8')
896
+ const parsed = JSON.parse(data)
897
+ // Ensure starred field exists for older data
898
+ return parsed.map(f => ({ ...f, starred: f.starred || false }))
899
+ }
900
+ } catch (err) {
901
+ console.warn('Failed to load published metadata, using empty list:', err.message)
902
+ }
903
+ return []
904
+ }
905
+
906
+ #savePublishedMetadata() {
907
+ try {
908
+ const metadataPath = this.#getMetadataPath()
909
+ fs.writeFileSync(metadataPath, JSON.stringify(this.#publishedFiles, null, 2), 'utf-8')
910
+ } catch (err) {
911
+ console.error('Failed to save published metadata:', err.message)
912
+ }
913
+ }
914
+
915
+ #loadTrashMetadata() {
916
+ try {
917
+ const metadataPath = this.#getTrashMetadataPath()
918
+ if (fs.existsSync(metadataPath)) {
919
+ const data = fs.readFileSync(metadataPath, 'utf-8')
920
+ return JSON.parse(data)
921
+ }
922
+ } catch (err) {
923
+ console.warn('Failed to load trash metadata, using empty list:', err.message)
924
+ }
925
+ return []
926
+ }
927
+
928
+ #saveTrashMetadata() {
929
+ try {
930
+ const metadataPath = this.#getTrashMetadataPath()
931
+ fs.writeFileSync(metadataPath, JSON.stringify(this.#trashFiles, null, 2), 'utf-8')
932
+ } catch (err) {
933
+ console.error('Failed to save trash metadata:', err.message)
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Wait for drive content to be available from peers or local
939
+ * @param {Hyperdrive} drive - The drive to check
940
+ * @param {number} timeout - Maximum wait time in ms
941
+ * @param {string} [taskId] - Task ID for cancellation
942
+ * @param {object} [taskState] - Task state object
943
+ * @returns {Promise<Array>} - List of entries
944
+ */
945
+ async #waitForDriveContent(drive, timeout, taskId = null, taskState = null) {
946
+ const startTime = Date.now()
947
+ const checkInterval = 1000 // Check every second
948
+ let lastPeerCount = 0
949
+ let lastStatus = ''
950
+ let bootstrapNodesChecked = false
951
+
952
+ // First, check if content is already available locally (for self-published files)
953
+ const localEntries = []
954
+ try {
955
+ for await (const entry of drive.list()) {
956
+ localEntries.push(entry)
957
+ }
958
+ if (localEntries.length > 0) {
959
+ console.log(`[MostBox] Found ${localEntries.length} entries locally`)
960
+ this.emit('download:status', { taskId, status: 'syncing' })
961
+ return localEntries
962
+ }
963
+ } catch (err) {
964
+ // Continue to peer discovery
965
+ }
966
+
967
+ while (Date.now() - startTime < timeout) {
968
+ // Check cancellation
969
+ if (taskState && taskState.aborted) {
970
+ throw new Error('Download cancelled')
971
+ }
972
+
973
+ const currentTime = Date.now()
974
+ const elapsed = Math.round((currentTime - startTime) / 1000)
975
+
976
+ // Check if we have peers
977
+ const currentPeerCount = this.#swarm.connections.size
978
+ const hasPeers = currentPeerCount > 0
979
+
980
+ // Log peer count changes
981
+ if (currentPeerCount !== lastPeerCount) {
982
+ console.log(`[MostBox] Peer count changed: ${lastPeerCount} -> ${currentPeerCount} (elapsed: ${elapsed}s)`)
983
+ lastPeerCount = currentPeerCount
984
+ }
985
+
986
+ // Try to list entries (works for both local and synced data)
987
+ const entries = []
988
+ try {
989
+ for await (const entry of drive.list()) {
990
+ entries.push(entry)
991
+ }
992
+ } catch (err) {
993
+ // Drive might not be ready yet
994
+ }
995
+
996
+ if (entries.length > 0) {
997
+ console.log(`[MostBox] Found ${entries.length} entries after ${elapsed}s`)
998
+ this.emit('download:status', { taskId, status: 'syncing' })
999
+ return entries
1000
+ }
1001
+
1002
+ // Update status based on peer connection
1003
+ if (hasPeers) {
1004
+ const newStatus = 'syncing'
1005
+ if (lastStatus !== newStatus) {
1006
+ this.emit('download:status', { taskId, status: newStatus })
1007
+ lastStatus = newStatus
1008
+ }
1009
+ } else {
1010
+ const newStatus = 'finding-peers'
1011
+ if (lastStatus !== newStatus) {
1012
+ this.emit('download:status', { taskId, status: newStatus })
1013
+ lastStatus = newStatus
1014
+ }
1015
+
1016
+ // Log progress every 30 seconds
1017
+ if (elapsed % 30 === 0 && elapsed > 0) {
1018
+ console.log(`[MostBox] Still waiting for peers... (${elapsed}s elapsed, timeout: ${timeout/1000}s)`)
1019
+
1020
+ // Check if bootstrap nodes are reachable (only once)
1021
+ if (!bootstrapNodesChecked && elapsed >= 60) {
1022
+ bootstrapNodesChecked = true
1023
+ console.log(`[MostBox] No peers found after 60s. This may indicate:`)
1024
+ console.log(`[MostBox] 1. Network/firewall blocking P2P connections`)
1025
+ console.log(`[MostBox] 2. DHT bootstrap nodes unreachable`)
1026
+ console.log(`[MostBox] 3. Publisher node offline`)
1027
+ console.log(`[MostBox] 4. NAT traversal failed`)
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ // Wait before next check
1033
+ await new Promise(resolve => setTimeout(resolve, checkInterval))
1034
+ }
1035
+
1036
+ console.log(`[MostBox] Timeout reached after ${timeout/1000}s, making final attempt...`)
1037
+
1038
+ // Final attempt - return whatever we have (might be empty)
1039
+ const entries = []
1040
+ try {
1041
+ for await (const entry of drive.list()) {
1042
+ entries.push(entry)
1043
+ }
1044
+ } catch (err) {
1045
+ console.log(`[MostBox] Final attempt failed: ${err.message}`)
1046
+ }
1047
+
1048
+ console.log(`[MostBox] Final entry count: ${entries.length}`)
1049
+
1050
+ // Provide detailed error information
1051
+ if (entries.length === 0) {
1052
+ const peerCount = this.#swarm.connections.size
1053
+ console.log(`[MostBox] Diagnostic information:`)
1054
+ console.log(`[MostBox] - Peer count: ${peerCount}`)
1055
+ console.log(`[MostBox] - Bootstrap nodes: ${SWARM_BOOTSTRAP.length}`)
1056
+ console.log(`[MostBox] - Timeout: ${timeout/1000}s`)
1057
+
1058
+ if (peerCount === 0) {
1059
+ console.log(`[MostBox] Suggestion: Check network connectivity and firewall settings`)
1060
+ } else {
1061
+ console.log(`[MostBox] Suggestion: Publisher may be offline or file may have been removed`)
1062
+ }
1063
+ }
1064
+
1065
+ return entries
1066
+ }
1067
+ }
1068
+
1069
+ // Re-export utilities
1070
+ export * from './config.js'
1071
+ export * from './core/cid.js'
1072
+ export * from './utils/errors.js'
1073
+ export * from './utils/security.js'