@ruvector/edge-net 0.5.0 → 0.5.3

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.
@@ -0,0 +1,791 @@
1
+ /**
2
+ * @ruvector/edge-net Distribution Manager
3
+ *
4
+ * Handles model distribution across multiple sources:
5
+ * - Google Cloud Storage (GCS)
6
+ * - IPFS (via web3.storage or nft.storage)
7
+ * - CDN with fallback support
8
+ *
9
+ * Features:
10
+ * - Integrity verification (SHA256)
11
+ * - Progress tracking for large files
12
+ * - Automatic source failover
13
+ *
14
+ * @module @ruvector/edge-net/models/distribution
15
+ */
16
+
17
+ import { EventEmitter } from 'events';
18
+ import { createHash, randomBytes } from 'crypto';
19
+ import { promises as fs } from 'fs';
20
+ import path from 'path';
21
+ import https from 'https';
22
+ import http from 'http';
23
+ import { URL } from 'url';
24
+
25
+ // ============================================
26
+ // CONSTANTS
27
+ // ============================================
28
+
29
+ const DEFAULT_GCS_BUCKET = 'ruvector-models';
30
+ const DEFAULT_CDN_BASE = 'https://models.ruvector.dev';
31
+ const DEFAULT_IPFS_GATEWAY = 'https://w3s.link/ipfs';
32
+
33
+ const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
34
+ const MAX_RETRIES = 3;
35
+ const RETRY_DELAY_MS = 1000;
36
+
37
+ // ============================================
38
+ // SOURCE TYPES
39
+ // ============================================
40
+
41
+ /**
42
+ * Source priority order (lower = higher priority)
43
+ */
44
+ export const SOURCE_PRIORITY = {
45
+ cdn: 1,
46
+ gcs: 2,
47
+ ipfs: 3,
48
+ fallback: 99,
49
+ };
50
+
51
+ /**
52
+ * Source URL patterns
53
+ */
54
+ export const SOURCE_PATTERNS = {
55
+ gcs: /^gs:\/\/([^/]+)\/(.+)$/,
56
+ ipfs: /^ipfs:\/\/(.+)$/,
57
+ http: /^https?:\/\/.+$/,
58
+ };
59
+
60
+ // ============================================
61
+ // PROGRESS TRACKER
62
+ // ============================================
63
+
64
+ /**
65
+ * Progress tracker for file transfers
66
+ */
67
+ export class ProgressTracker extends EventEmitter {
68
+ constructor(totalBytes = 0) {
69
+ super();
70
+ this.totalBytes = totalBytes;
71
+ this.bytesTransferred = 0;
72
+ this.startTime = Date.now();
73
+ this.lastUpdateTime = Date.now();
74
+ this.lastBytesTransferred = 0;
75
+ }
76
+
77
+ /**
78
+ * Update progress
79
+ * @param {number} bytes - Bytes transferred in this chunk
80
+ */
81
+ update(bytes) {
82
+ this.bytesTransferred += bytes;
83
+ const now = Date.now();
84
+
85
+ // Calculate speed (bytes per second)
86
+ const timeDelta = (now - this.lastUpdateTime) / 1000;
87
+ const bytesDelta = this.bytesTransferred - this.lastBytesTransferred;
88
+ const speed = timeDelta > 0 ? bytesDelta / timeDelta : 0;
89
+
90
+ // Calculate ETA
91
+ const remaining = this.totalBytes - this.bytesTransferred;
92
+ const eta = speed > 0 ? remaining / speed : 0;
93
+
94
+ const progress = {
95
+ bytesTransferred: this.bytesTransferred,
96
+ totalBytes: this.totalBytes,
97
+ percent: this.totalBytes > 0
98
+ ? Math.round((this.bytesTransferred / this.totalBytes) * 100)
99
+ : 0,
100
+ speed: Math.round(speed),
101
+ speedMBps: Math.round(speed / (1024 * 1024) * 100) / 100,
102
+ eta: Math.round(eta),
103
+ elapsed: Math.round((now - this.startTime) / 1000),
104
+ };
105
+
106
+ this.lastUpdateTime = now;
107
+ this.lastBytesTransferred = this.bytesTransferred;
108
+
109
+ this.emit('progress', progress);
110
+
111
+ if (this.bytesTransferred >= this.totalBytes) {
112
+ this.emit('complete', progress);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Mark as complete
118
+ */
119
+ complete() {
120
+ this.bytesTransferred = this.totalBytes;
121
+ const elapsed = (Date.now() - this.startTime) / 1000;
122
+
123
+ this.emit('complete', {
124
+ bytesTransferred: this.bytesTransferred,
125
+ totalBytes: this.totalBytes,
126
+ percent: 100,
127
+ elapsed: Math.round(elapsed),
128
+ averageSpeed: Math.round(this.totalBytes / elapsed),
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Mark as failed
134
+ * @param {Error} error - Failure error
135
+ */
136
+ fail(error) {
137
+ this.emit('error', {
138
+ error,
139
+ bytesTransferred: this.bytesTransferred,
140
+ totalBytes: this.totalBytes,
141
+ });
142
+ }
143
+ }
144
+
145
+ // ============================================
146
+ // DISTRIBUTION MANAGER
147
+ // ============================================
148
+
149
+ /**
150
+ * DistributionManager - Manages model uploads and downloads
151
+ */
152
+ export class DistributionManager extends EventEmitter {
153
+ /**
154
+ * Create a new DistributionManager
155
+ * @param {object} options - Configuration options
156
+ */
157
+ constructor(options = {}) {
158
+ super();
159
+
160
+ this.id = `dist-${randomBytes(6).toString('hex')}`;
161
+
162
+ // GCS configuration
163
+ this.gcsConfig = {
164
+ bucket: options.gcsBucket || DEFAULT_GCS_BUCKET,
165
+ projectId: options.gcsProjectId || process.env.GCS_PROJECT_ID,
166
+ keyFilePath: options.gcsKeyFile || process.env.GOOGLE_APPLICATION_CREDENTIALS,
167
+ };
168
+
169
+ // IPFS configuration
170
+ this.ipfsConfig = {
171
+ gateway: options.ipfsGateway || DEFAULT_IPFS_GATEWAY,
172
+ web3StorageToken: options.web3StorageToken || process.env.WEB3_STORAGE_TOKEN,
173
+ nftStorageToken: options.nftStorageToken || process.env.NFT_STORAGE_TOKEN,
174
+ };
175
+
176
+ // CDN configuration
177
+ this.cdnConfig = {
178
+ baseUrl: options.cdnBaseUrl || DEFAULT_CDN_BASE,
179
+ fallbackUrls: options.cdnFallbacks || [],
180
+ };
181
+
182
+ // Download cache (in-flight downloads)
183
+ this.activeDownloads = new Map();
184
+
185
+ // Stats
186
+ this.stats = {
187
+ uploads: 0,
188
+ downloads: 0,
189
+ bytesUploaded: 0,
190
+ bytesDownloaded: 0,
191
+ failures: 0,
192
+ };
193
+ }
194
+
195
+ // ============================================
196
+ // URL GENERATION
197
+ // ============================================
198
+
199
+ /**
200
+ * Generate CDN URL for a model
201
+ * @param {string} modelName - Model name
202
+ * @param {string} version - Model version
203
+ * @param {string} filename - Filename
204
+ * @returns {string}
205
+ */
206
+ getCdnUrl(modelName, version, filename = null) {
207
+ const file = filename || `${modelName}.onnx`;
208
+ return `${this.cdnConfig.baseUrl}/${modelName}/${version}/${file}`;
209
+ }
210
+
211
+ /**
212
+ * Generate GCS URL for a model
213
+ * @param {string} modelName - Model name
214
+ * @param {string} version - Model version
215
+ * @param {string} filename - Filename
216
+ * @returns {string}
217
+ */
218
+ getGcsUrl(modelName, version, filename = null) {
219
+ const file = filename || `${modelName}.onnx`;
220
+ return `gs://${this.gcsConfig.bucket}/${modelName}/${version}/${file}`;
221
+ }
222
+
223
+ /**
224
+ * Generate IPFS URL from CID
225
+ * @param {string} cid - IPFS Content ID
226
+ * @returns {string}
227
+ */
228
+ getIpfsUrl(cid) {
229
+ return `ipfs://${cid}`;
230
+ }
231
+
232
+ /**
233
+ * Generate HTTP gateway URL for IPFS
234
+ * @param {string} cid - IPFS Content ID
235
+ * @returns {string}
236
+ */
237
+ getIpfsGatewayUrl(cid) {
238
+ // Handle both ipfs:// URLs and raw CIDs
239
+ const cleanCid = cid.replace(/^ipfs:\/\//, '');
240
+ return `${this.ipfsConfig.gateway}/${cleanCid}`;
241
+ }
242
+
243
+ /**
244
+ * Generate all source URLs for a model
245
+ * @param {object} sources - Source configuration from metadata
246
+ * @param {string} modelName - Model name
247
+ * @param {string} version - Version
248
+ * @returns {object[]} Sorted list of sources with URLs
249
+ */
250
+ generateSourceUrls(sources, modelName, version) {
251
+ const urls = [];
252
+
253
+ // CDN (highest priority)
254
+ if (sources.cdn) {
255
+ urls.push({
256
+ type: 'cdn',
257
+ url: sources.cdn,
258
+ priority: SOURCE_PRIORITY.cdn,
259
+ });
260
+ } else {
261
+ // Auto-generate CDN URL
262
+ urls.push({
263
+ type: 'cdn',
264
+ url: this.getCdnUrl(modelName, version),
265
+ priority: SOURCE_PRIORITY.cdn,
266
+ });
267
+ }
268
+
269
+ // GCS
270
+ if (sources.gcs) {
271
+ const gcsMatch = sources.gcs.match(SOURCE_PATTERNS.gcs);
272
+ if (gcsMatch) {
273
+ // Convert gs:// to HTTPS URL
274
+ const [, bucket, path] = gcsMatch;
275
+ urls.push({
276
+ type: 'gcs',
277
+ url: `https://storage.googleapis.com/${bucket}/${path}`,
278
+ originalUrl: sources.gcs,
279
+ priority: SOURCE_PRIORITY.gcs,
280
+ });
281
+ }
282
+ }
283
+
284
+ // IPFS
285
+ if (sources.ipfs) {
286
+ urls.push({
287
+ type: 'ipfs',
288
+ url: this.getIpfsGatewayUrl(sources.ipfs),
289
+ originalUrl: sources.ipfs,
290
+ priority: SOURCE_PRIORITY.ipfs,
291
+ });
292
+ }
293
+
294
+ // Fallback URLs
295
+ for (const fallback of this.cdnConfig.fallbackUrls) {
296
+ urls.push({
297
+ type: 'fallback',
298
+ url: `${fallback}/${modelName}/${version}/${modelName}.onnx`,
299
+ priority: SOURCE_PRIORITY.fallback,
300
+ });
301
+ }
302
+
303
+ // Sort by priority
304
+ return urls.sort((a, b) => a.priority - b.priority);
305
+ }
306
+
307
+ // ============================================
308
+ // DOWNLOAD
309
+ // ============================================
310
+
311
+ /**
312
+ * Download a model from the best available source
313
+ * @param {object} metadata - Model metadata
314
+ * @param {object} options - Download options
315
+ * @returns {Promise<Buffer>}
316
+ */
317
+ async download(metadata, options = {}) {
318
+ const { name, version, sources, size, hash } = metadata;
319
+ const key = `${name}@${version}`;
320
+
321
+ // Check for in-flight download
322
+ if (this.activeDownloads.has(key)) {
323
+ return this.activeDownloads.get(key);
324
+ }
325
+
326
+ const downloadPromise = this._executeDownload(metadata, options);
327
+ this.activeDownloads.set(key, downloadPromise);
328
+
329
+ try {
330
+ const result = await downloadPromise;
331
+ return result;
332
+ } finally {
333
+ this.activeDownloads.delete(key);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Execute the download with fallback
339
+ * @private
340
+ */
341
+ async _executeDownload(metadata, options = {}) {
342
+ const { name, version, sources, size, hash } = metadata;
343
+ const sourceUrls = this.generateSourceUrls(sources, name, version);
344
+
345
+ const progress = new ProgressTracker(size);
346
+
347
+ if (options.onProgress) {
348
+ progress.on('progress', options.onProgress);
349
+ }
350
+
351
+ let lastError = null;
352
+
353
+ for (const source of sourceUrls) {
354
+ try {
355
+ this.emit('download_attempt', { source, model: name, version });
356
+
357
+ const data = await this._downloadFromUrl(source.url, {
358
+ ...options,
359
+ progress,
360
+ expectedSize: size,
361
+ });
362
+
363
+ // Verify integrity
364
+ if (hash) {
365
+ const computedHash = `sha256:${createHash('sha256').update(data).digest('hex')}`;
366
+ if (computedHash !== hash) {
367
+ throw new Error(`Hash mismatch: expected ${hash}, got ${computedHash}`);
368
+ }
369
+ }
370
+
371
+ this.stats.downloads++;
372
+ this.stats.bytesDownloaded += data.length;
373
+
374
+ progress.complete();
375
+
376
+ this.emit('download_complete', {
377
+ source,
378
+ model: name,
379
+ version,
380
+ size: data.length,
381
+ });
382
+
383
+ return data;
384
+
385
+ } catch (error) {
386
+ lastError = error;
387
+ this.emit('download_failed', {
388
+ source,
389
+ model: name,
390
+ version,
391
+ error: error.message,
392
+ });
393
+
394
+ // Continue to next source
395
+ continue;
396
+ }
397
+ }
398
+
399
+ this.stats.failures++;
400
+ progress.fail(lastError);
401
+
402
+ throw new Error(`Failed to download ${name}@${version} from all sources: ${lastError?.message}`);
403
+ }
404
+
405
+ /**
406
+ * Download from a URL with streaming and progress
407
+ * @private
408
+ */
409
+ _downloadFromUrl(url, options = {}) {
410
+ return new Promise((resolve, reject) => {
411
+ const { progress, expectedSize, timeout = 60000 } = options;
412
+ const parsedUrl = new URL(url);
413
+ const protocol = parsedUrl.protocol === 'https:' ? https : http;
414
+
415
+ const chunks = [];
416
+ let bytesReceived = 0;
417
+
418
+ const request = protocol.get(url, {
419
+ timeout,
420
+ headers: {
421
+ 'User-Agent': 'RuVector-EdgeNet/1.0',
422
+ 'Accept': 'application/octet-stream',
423
+ },
424
+ }, (response) => {
425
+ // Handle redirects
426
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
427
+ this._downloadFromUrl(response.headers.location, options)
428
+ .then(resolve)
429
+ .catch(reject);
430
+ return;
431
+ }
432
+
433
+ if (response.statusCode !== 200) {
434
+ reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
435
+ return;
436
+ }
437
+
438
+ const contentLength = parseInt(response.headers['content-length'] || expectedSize || 0, 10);
439
+ if (progress && contentLength) {
440
+ progress.totalBytes = contentLength;
441
+ }
442
+
443
+ response.on('data', (chunk) => {
444
+ chunks.push(chunk);
445
+ bytesReceived += chunk.length;
446
+
447
+ if (progress) {
448
+ progress.update(chunk.length);
449
+ }
450
+ });
451
+
452
+ response.on('end', () => {
453
+ const data = Buffer.concat(chunks);
454
+ resolve(data);
455
+ });
456
+
457
+ response.on('error', reject);
458
+ });
459
+
460
+ request.on('error', reject);
461
+ request.on('timeout', () => {
462
+ request.destroy();
463
+ reject(new Error('Request timeout'));
464
+ });
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Download to a file with streaming
470
+ * @param {object} metadata - Model metadata
471
+ * @param {string} destPath - Destination file path
472
+ * @param {object} options - Download options
473
+ */
474
+ async downloadToFile(metadata, destPath, options = {}) {
475
+ const data = await this.download(metadata, options);
476
+
477
+ // Ensure directory exists
478
+ const dir = path.dirname(destPath);
479
+ await fs.mkdir(dir, { recursive: true });
480
+
481
+ await fs.writeFile(destPath, data);
482
+
483
+ return {
484
+ path: destPath,
485
+ size: data.length,
486
+ };
487
+ }
488
+
489
+ // ============================================
490
+ // UPLOAD
491
+ // ============================================
492
+
493
+ /**
494
+ * Upload a model to Google Cloud Storage
495
+ * @param {Buffer} data - Model data
496
+ * @param {string} modelName - Model name
497
+ * @param {string} version - Version
498
+ * @param {object} options - Upload options
499
+ * @returns {Promise<string>} GCS URL
500
+ */
501
+ async uploadToGcs(data, modelName, version, options = {}) {
502
+ const { filename = `${modelName}.onnx` } = options;
503
+ const gcsPath = `${modelName}/${version}/${filename}`;
504
+
505
+ // Check for @google-cloud/storage
506
+ let storage;
507
+ try {
508
+ const { Storage } = await import('@google-cloud/storage');
509
+ storage = new Storage({
510
+ projectId: this.gcsConfig.projectId,
511
+ keyFilename: this.gcsConfig.keyFilePath,
512
+ });
513
+ } catch (error) {
514
+ throw new Error('GCS upload requires @google-cloud/storage package');
515
+ }
516
+
517
+ const bucket = storage.bucket(this.gcsConfig.bucket);
518
+ const file = bucket.file(gcsPath);
519
+
520
+ const progress = new ProgressTracker(data.length);
521
+
522
+ if (options.onProgress) {
523
+ progress.on('progress', options.onProgress);
524
+ }
525
+
526
+ await new Promise((resolve, reject) => {
527
+ const stream = file.createWriteStream({
528
+ metadata: {
529
+ contentType: 'application/octet-stream',
530
+ metadata: {
531
+ modelName,
532
+ version,
533
+ hash: `sha256:${createHash('sha256').update(data).digest('hex')}`,
534
+ },
535
+ },
536
+ });
537
+
538
+ stream.on('error', reject);
539
+ stream.on('finish', resolve);
540
+
541
+ // Write in chunks for progress tracking
542
+ let offset = 0;
543
+ const writeChunk = () => {
544
+ while (offset < data.length) {
545
+ const end = Math.min(offset + CHUNK_SIZE, data.length);
546
+ const chunk = data.slice(offset, end);
547
+
548
+ if (!stream.write(chunk)) {
549
+ offset = end;
550
+ stream.once('drain', writeChunk);
551
+ return;
552
+ }
553
+
554
+ progress.update(chunk.length);
555
+ offset = end;
556
+ }
557
+ stream.end();
558
+ };
559
+
560
+ writeChunk();
561
+ });
562
+
563
+ progress.complete();
564
+ this.stats.uploads++;
565
+ this.stats.bytesUploaded += data.length;
566
+
567
+ const gcsUrl = this.getGcsUrl(modelName, version, filename);
568
+
569
+ this.emit('upload_complete', {
570
+ type: 'gcs',
571
+ url: gcsUrl,
572
+ model: modelName,
573
+ version,
574
+ size: data.length,
575
+ });
576
+
577
+ return gcsUrl;
578
+ }
579
+
580
+ /**
581
+ * Upload a model to IPFS via web3.storage
582
+ * @param {Buffer} data - Model data
583
+ * @param {string} modelName - Model name
584
+ * @param {string} version - Version
585
+ * @param {object} options - Upload options
586
+ * @returns {Promise<string>} IPFS CID
587
+ */
588
+ async uploadToIpfs(data, modelName, version, options = {}) {
589
+ const { filename = `${modelName}.onnx`, provider = 'web3storage' } = options;
590
+
591
+ let cid;
592
+
593
+ if (provider === 'web3storage' && this.ipfsConfig.web3StorageToken) {
594
+ cid = await this._uploadToWeb3Storage(data, filename);
595
+ } else if (provider === 'nftstorage' && this.ipfsConfig.nftStorageToken) {
596
+ cid = await this._uploadToNftStorage(data, filename);
597
+ } else {
598
+ throw new Error('No IPFS provider configured. Set WEB3_STORAGE_TOKEN or NFT_STORAGE_TOKEN');
599
+ }
600
+
601
+ this.stats.uploads++;
602
+ this.stats.bytesUploaded += data.length;
603
+
604
+ const ipfsUrl = this.getIpfsUrl(cid);
605
+
606
+ this.emit('upload_complete', {
607
+ type: 'ipfs',
608
+ url: ipfsUrl,
609
+ cid,
610
+ model: modelName,
611
+ version,
612
+ size: data.length,
613
+ });
614
+
615
+ return ipfsUrl;
616
+ }
617
+
618
+ /**
619
+ * Upload to web3.storage
620
+ * @private
621
+ */
622
+ async _uploadToWeb3Storage(data, filename) {
623
+ // web3.storage API upload
624
+ const response = await this._httpRequest('https://api.web3.storage/upload', {
625
+ method: 'POST',
626
+ headers: {
627
+ 'Authorization': `Bearer ${this.ipfsConfig.web3StorageToken}`,
628
+ 'X-Name': filename,
629
+ },
630
+ body: data,
631
+ });
632
+
633
+ if (!response.cid) {
634
+ throw new Error('web3.storage upload failed: no CID returned');
635
+ }
636
+
637
+ return response.cid;
638
+ }
639
+
640
+ /**
641
+ * Upload to nft.storage
642
+ * @private
643
+ */
644
+ async _uploadToNftStorage(data, filename) {
645
+ // nft.storage API upload
646
+ const response = await this._httpRequest('https://api.nft.storage/upload', {
647
+ method: 'POST',
648
+ headers: {
649
+ 'Authorization': `Bearer ${this.ipfsConfig.nftStorageToken}`,
650
+ },
651
+ body: data,
652
+ });
653
+
654
+ if (!response.value?.cid) {
655
+ throw new Error('nft.storage upload failed: no CID returned');
656
+ }
657
+
658
+ return response.value.cid;
659
+ }
660
+
661
+ /**
662
+ * Make an HTTP request
663
+ * @private
664
+ */
665
+ _httpRequest(url, options = {}) {
666
+ return new Promise((resolve, reject) => {
667
+ const parsedUrl = new URL(url);
668
+ const protocol = parsedUrl.protocol === 'https:' ? https : http;
669
+
670
+ const requestOptions = {
671
+ method: options.method || 'GET',
672
+ headers: options.headers || {},
673
+ hostname: parsedUrl.hostname,
674
+ path: parsedUrl.pathname + parsedUrl.search,
675
+ port: parsedUrl.port,
676
+ };
677
+
678
+ const request = protocol.request(requestOptions, (response) => {
679
+ const chunks = [];
680
+
681
+ response.on('data', chunk => chunks.push(chunk));
682
+ response.on('end', () => {
683
+ const body = Buffer.concat(chunks).toString('utf-8');
684
+
685
+ if (response.statusCode >= 400) {
686
+ reject(new Error(`HTTP ${response.statusCode}: ${body}`));
687
+ return;
688
+ }
689
+
690
+ try {
691
+ resolve(JSON.parse(body));
692
+ } catch {
693
+ resolve(body);
694
+ }
695
+ });
696
+ });
697
+
698
+ request.on('error', reject);
699
+
700
+ if (options.body) {
701
+ request.write(options.body);
702
+ }
703
+
704
+ request.end();
705
+ });
706
+ }
707
+
708
+ // ============================================
709
+ // INTEGRITY VERIFICATION
710
+ // ============================================
711
+
712
+ /**
713
+ * Compute SHA256 hash of data
714
+ * @param {Buffer} data - Data to hash
715
+ * @returns {string} Hash string with sha256: prefix
716
+ */
717
+ computeHash(data) {
718
+ return `sha256:${createHash('sha256').update(data).digest('hex')}`;
719
+ }
720
+
721
+ /**
722
+ * Verify data integrity against expected hash
723
+ * @param {Buffer} data - Data to verify
724
+ * @param {string} expectedHash - Expected hash
725
+ * @returns {boolean}
726
+ */
727
+ verifyIntegrity(data, expectedHash) {
728
+ const computed = this.computeHash(data);
729
+ return computed === expectedHash;
730
+ }
731
+
732
+ /**
733
+ * Verify a downloaded model
734
+ * @param {Buffer} data - Model data
735
+ * @param {object} metadata - Model metadata
736
+ * @returns {object} Verification result
737
+ */
738
+ verifyModel(data, metadata) {
739
+ const result = {
740
+ valid: true,
741
+ checks: [],
742
+ };
743
+
744
+ // Size check
745
+ if (metadata.size) {
746
+ const sizeMatch = data.length === metadata.size;
747
+ result.checks.push({
748
+ type: 'size',
749
+ expected: metadata.size,
750
+ actual: data.length,
751
+ passed: sizeMatch,
752
+ });
753
+ if (!sizeMatch) result.valid = false;
754
+ }
755
+
756
+ // Hash check
757
+ if (metadata.hash) {
758
+ const hashMatch = this.verifyIntegrity(data, metadata.hash);
759
+ result.checks.push({
760
+ type: 'hash',
761
+ expected: metadata.hash,
762
+ actual: this.computeHash(data),
763
+ passed: hashMatch,
764
+ });
765
+ if (!hashMatch) result.valid = false;
766
+ }
767
+
768
+ return result;
769
+ }
770
+
771
+ // ============================================
772
+ // STATS AND INFO
773
+ // ============================================
774
+
775
+ /**
776
+ * Get distribution manager stats
777
+ * @returns {object}
778
+ */
779
+ getStats() {
780
+ return {
781
+ ...this.stats,
782
+ activeDownloads: this.activeDownloads.size,
783
+ };
784
+ }
785
+ }
786
+
787
+ // ============================================
788
+ // DEFAULT EXPORT
789
+ // ============================================
790
+
791
+ export default DistributionManager;