@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.
- package/README.md +281 -10
- package/core-invariants.js +942 -0
- package/models/adapter-hub.js +1008 -0
- package/models/adapter-security.js +792 -0
- package/models/benchmark.js +688 -0
- package/models/distribution.js +791 -0
- package/models/index.js +109 -0
- package/models/integrity.js +753 -0
- package/models/loader.js +725 -0
- package/models/microlora.js +1298 -0
- package/models/model-loader.js +922 -0
- package/models/model-optimizer.js +1245 -0
- package/models/model-registry.js +696 -0
- package/models/model-utils.js +548 -0
- package/models/models-cli.js +914 -0
- package/models/registry.json +214 -0
- package/models/training-utils.js +1418 -0
- package/models/wasm-core.js +1025 -0
- package/network-genesis.js +2847 -0
- package/onnx-worker.js +462 -8
- package/package.json +33 -3
- package/plugins/SECURITY-AUDIT.md +654 -0
- package/plugins/cli.js +43 -3
- package/plugins/implementations/e2e-encryption.js +57 -12
- package/plugins/plugin-loader.js +610 -21
- package/tests/model-optimizer.test.js +644 -0
- package/tests/network-genesis.test.js +562 -0
- package/tests/plugin-benchmark.js +1239 -0
- package/tests/plugin-system-test.js +163 -0
- package/tests/wasm-core.test.js +368 -0
|
@@ -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;
|