@toast-studios/asset-manager
Version:
A React Native asset management library with intelligent caching and loading strategies
507 lines (506 loc) • 19.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ToastAssetManager = void 0;
const index_1 = require("../types/index");
const NetworkAwareStrategy_1 = require("../strategies/NetworkAwareStrategy");
const NetworkMonitor_1 = require("../utils/NetworkMonitor");
const StorageManager_1 = require("../utils/StorageManager");
const DownloadManager_1 = require("../utils/DownloadManager");
/**
* Main Toast Asset Manager class for intelligent asset downloading and management
*/
class ToastAssetManager {
constructor(config) {
var _a, _b, _c, _d, _e;
// State management
this.downloadQueue = new Map();
this.processing = false;
this.destroyed = false;
// Initialize configuration with defaults
this.config = {
basePath: config.basePath || '/assets',
strategy: config.strategy || index_1.DownloadStrategy.NETWORK_AWARE,
maxConcurrentDownloads: config.maxConcurrentDownloads || 3,
enableNetworkDetection: (_a = config.enableNetworkDetection) !== null && _a !== void 0 ? _a : true,
pingTimeout: config.pingTimeout || 5000,
pingTargets: config.pingTargets,
appVersion: config.appVersion,
manifestUrl: config.manifestUrl,
appLanguage: config.appLanguage,
retryAttempts: config.retryAttempts || 3,
retryDelay: config.retryDelay || 1000,
enableIntegrityCheck: (_b = config.enableIntegrityCheck) !== null && _b !== void 0 ? _b : true,
enableAutoCleanup: (_c = config.enableAutoCleanup) !== null && _c !== void 0 ? _c : true,
cacheLimit: (_d = config.cacheLimit) !== null && _d !== void 0 ? _d : 500 * 1024 * 1024,
customHeaders: config.customHeaders || {},
enableLogging: (_e = config.enableLogging) !== null && _e !== void 0 ? _e : false,
};
// Initialize components
this.networkMonitor = new NetworkMonitor_1.NetworkMonitor(this.config.pingTimeout, this.config.pingTargets);
this.storageManager = new StorageManager_1.StorageManager(this.config.basePath, this.config.enableLogging);
this.downloadManager = new DownloadManager_1.DownloadManager(this.storageManager, this.config.enableLogging, this.config.customHeaders);
// Initialize strategy (always NetworkAware for optimization)
this.strategy = new NetworkAwareStrategy_1.NetworkAwareStrategy();
this.log('ToastAssetManager initialized');
}
/**
* Start the complete asset management process - initialize, load manifest, and begin downloads
*/
async start(manifest) {
if (this.destroyed) {
throw new Error('ToastAssetManager has been destroyed');
}
try {
this.log('Starting ToastAssetManager...');
// Step 1: Initialize the system
await this.initialize();
// Step 2: Load manifest
await this.loadManifest(manifest);
// Step 3: Start downloads
await this.startDownloads();
this.log('ToastAssetManager started successfully');
}
catch (error) {
throw new Error(`Failed to start ToastAssetManager: ${error}`);
}
}
/**
* Initialize the asset manager
*/
async initialize() {
if (this.destroyed) {
throw new Error('ToastAssetManager has been destroyed');
}
try {
// Initialize storage
await this.storageManager.initialize();
// Start network monitoring if enabled
if (this.config.enableNetworkDetection) {
this.currentNetworkConditions =
await this.networkMonitor.getCurrentConditions();
this.networkMonitor.startMonitoring(conditions => {
this.handleNetworkChange(conditions);
});
}
// Start auto cleanup if enabled
if (this.config.enableAutoCleanup) {
this.startAutoCleanup();
}
this.log('ToastAssetManager initialization completed');
}
catch (error) {
throw new Error(`Failed to initialize ToastAssetManager: ${error}`);
}
}
/**
* Load asset manifest - either from parameter or from configured URL
*/
async loadManifest(manifest) {
if (this.destroyed) {
throw new Error('ToastAssetManager has been destroyed');
}
try {
if (manifest) {
// Load provided manifest directly
this.manifest = manifest;
this.log(`Loaded manifest from parameter: ${manifest.bundles.length} bundles, ${manifest.version}`);
}
else if (this.config.manifestUrl) {
// Fetch manifest from URL
this.manifest = await this.fetchManifestFromUrl();
this.log(`Loaded manifest from URL: ${this.manifest.bundles.length} bundles, ${this.manifest.version}`);
}
else {
throw new Error('No manifest provided and no manifestUrl configured');
}
}
catch (error) {
throw new Error(`Failed to load manifest: ${error}`);
}
}
/**
* Fetch manifest from the configured URL with app version and language parameters
*/
async fetchManifestFromUrl() {
if (!this.config.manifestUrl) {
throw new Error('Manifest URL is not configured');
}
try {
// Build URL with query parameters
const url = new URL(this.config.manifestUrl);
if (this.config.appVersion) {
url.searchParams.append('appVersion', this.config.appVersion);
}
if (this.config.appLanguage) {
url.searchParams.append('language', this.config.appLanguage);
}
this.log(`Fetching manifest from: ${url.toString()}`);
// Make GET request with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.pingTimeout || 30000);
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...this.config.customHeaders,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const manifest = (await response.json());
// Validate manifest structure
if (!manifest.version ||
!manifest.bundles ||
!Array.isArray(manifest.bundles)) {
throw new Error('Invalid manifest structure: missing version or bundles array');
}
this.log(`Fetched manifest version: ${manifest.version} with ${manifest.bundles.length} bundles`);
return manifest;
}
catch (error) {
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error(`Network error while fetching manifest: ${error.message}`);
}
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Manifest fetch request timed out');
}
throw error;
}
}
/**
* Start downloading assets based on current strategy
*/
async startDownloads() {
if (this.destroyed) {
throw new Error('ToastAssetManager has been destroyed');
}
if (!this.manifest) {
throw new Error('No manifest loaded');
}
if (this.processing) {
this.log('Downloads already in progress');
return;
}
this.processing = true;
try {
// Get current network conditions
const conditions = this.currentNetworkConditions ||
(await this.networkMonitor.getCurrentConditions());
// Filter and prioritize bundles based on strategy
const bundlesToDownload = this.manifest.bundles.filter(bundle => this.strategy.shouldDownload(bundle, conditions));
const prioritizedBundles = this.strategy.prioritizeBundles(bundlesToDownload, conditions);
// Add bundles to download queue
for (const bundle of prioritizedBundles) {
await this.queueDownload(bundle);
}
// Start processing queue
await this.processDownloadQueue();
}
catch (error) {
this.log(`Error starting downloads: ${error}`);
throw error;
}
finally {
this.processing = false;
}
}
/**
* Check if game is ready to play (all default assets downloaded)
*/
async isGameReadyToPlay() {
var _a;
if (!this.manifest) {
return false;
}
try {
// Check if all default assets are available
for (const assetId of this.manifest.defaultAssets) {
const asset = this.findAssetById(assetId);
if (!asset)
continue;
const assetPath = this.getAssetPath(asset);
const exists = await this.storageManager.assetExistsById(asset.id, assetPath, asset.etag);
if (!exists) {
return false;
}
// Validate integrity if enabled
if (this.config.enableIntegrityCheck && asset.hash) {
const validation = await this.storageManager.validateAssetById(asset.id, assetPath, asset.hash);
if (!validation.isValid) {
return false;
}
}
}
(_a = this.gameReadyCallback) === null || _a === void 0 ? void 0 : _a.call(this, true);
return true;
}
catch (error) {
this.log(`Error checking game readiness: ${error}`);
return false;
}
}
/**
* Get path to a specific asset
*/
getAssetPath(asset) {
return this.storageManager.getExtractedPath(asset.key);
}
/**
* Get asset by ID
*/
getAsset(assetId) {
return this.findAssetById(assetId);
}
/**
* Get storage information
*/
async getStorageInfo() {
return this.storageManager.getStorageInfo();
}
/**
* Get hash-based deduplication statistics
*/
async getDeduplicationStats() {
const stats = await this.storageManager.getDeduplicationStats();
return {
...stats,
savedSpaceMB: Math.round((stats.savedSpace / (1024 * 1024)) * 100) / 100, // Convert to MB
};
}
/**
* Get the actual file path for an asset by ID (resolves hash-based references)
*/
async getAssetPathById(assetId) {
return this.storageManager.getAssetPath(assetId);
}
/**
* Cleanup old assets
*/
async cleanup() {
const options = this.config.cacheLimit
? { maxSize: this.config.cacheLimit }
: {};
const deletedCount = await this.storageManager.cleanup(options);
// Also cleanup temporary files
await this.downloadManager.cleanupTempFiles();
return deletedCount;
}
/**
* Set progress callback for download updates
*/
onProgress(callback) {
this.progressCallback = callback;
}
/**
* Set game ready callback
*/
onGameReady(callback) {
this.gameReadyCallback = callback;
}
/**
* Set network change callback
*/
onNetworkChange(callback) {
this.networkChangeCallback = callback;
}
/**
* Destroy the asset manager and cleanup resources
*/
async destroy() {
var _a, _b;
if (this.destroyed)
return;
this.destroyed = true;
this.processing = false;
// Stop all downloads
this.downloadManager.cancelAllDownloads();
// Stop monitoring
this.networkMonitor.stopMonitoring();
// Cleanup components
(_b = (_a = this.networkMonitor).destroy) === null || _b === void 0 ? void 0 : _b.call(_a);
this.downloadManager.destroy();
await this.storageManager.destroy(); // Ensure batched saves are flushed
// Clear state
this.downloadQueue.clear();
this.progressCallback = undefined;
this.gameReadyCallback = undefined;
this.networkChangeCallback = undefined;
this.log('ToastAssetManager destroyed');
}
/**
* Manually flush all pending storage operations to disk
* Useful before critical operations or app shutdown
*/
async flushStorage() {
if (this.destroyed) {
throw new Error('ToastAssetManager has been destroyed');
}
await this.storageManager.flush();
this.log('Storage operations flushed to disk');
}
/**
* Get storage batching statistics
* Useful for monitoring and debugging performance
*/
getStorageBatchingStats() {
if (this.destroyed) {
throw new Error('ToastAssetManager has been destroyed');
}
return this.storageManager.getBatchingStats();
}
/**
* Queue a bundle for download
*/
async queueDownload(bundle) {
if (this.downloadQueue.has(bundle.id)) {
return; // Already queued
}
// Check if bundle already exists and is valid
const bundlePath = this.storageManager.getBundlePath(`${bundle.id}.zip`);
const exists = await this.storageManager.assetExistsById(bundle.id, bundlePath, bundle.etag);
if (exists && bundle.hash) {
const validation = await this.storageManager.validateAssetById(bundle.id, bundlePath, bundle.hash);
if (validation.isValid) {
this.log(`Bundle ${bundle.id} already exists and is valid, skipping download`);
return;
}
}
const queueItem = {
bundle,
priority: this.getBundlePriority(bundle),
retryCount: 0,
};
this.downloadQueue.set(bundle.id, queueItem);
}
/**
* Process download queue with concurrency control
*/
async processDownloadQueue() {
const conditions = this.currentNetworkConditions ||
(await this.networkMonitor.getCurrentConditions());
const maxConcurrent = Math.min(this.config.maxConcurrentDownloads, this.strategy.getMaxConcurrentDownloads(conditions));
const activeDownloads = this.downloadManager.getActiveDownloadCount();
const availableSlots = maxConcurrent - activeDownloads;
if (availableSlots <= 0 || this.downloadQueue.size === 0) {
return;
}
// Sort queue by priority
const sortedQueue = Array.from(this.downloadQueue.values()).sort((a, b) => a.priority - b.priority);
// Start downloads for available slots
const downloadsToStart = sortedQueue.slice(0, availableSlots);
for (const queueItem of downloadsToStart) {
this.downloadQueue.delete(queueItem.bundle.id);
this.startBundleDownload(queueItem);
}
}
/**
* Start downloading a bundle
*/
async startBundleDownload(queueItem) {
var _a;
const { bundle } = queueItem;
try {
await this.downloadManager.downloadBundle(bundle, progress => {
var _a;
(_a = this.progressCallback) === null || _a === void 0 ? void 0 : _a.call(this, progress);
});
// Check if game is ready after each successful download
const isReady = await this.isGameReadyToPlay();
if (isReady) {
(_a = this.gameReadyCallback) === null || _a === void 0 ? void 0 : _a.call(this, true);
}
}
catch (error) {
// Retry logic
if (queueItem.retryCount < this.config.retryAttempts) {
queueItem.retryCount++;
// Add delay before retry
setTimeout(() => {
if (!this.destroyed) {
this.downloadQueue.set(bundle.id, queueItem);
this.processDownloadQueue();
}
}, this.config.retryDelay * queueItem.retryCount);
}
else {
this.log(`Max retries exceeded for bundle: ${bundle.id}`);
}
}
// Continue processing queue
if (!this.destroyed) {
await this.processDownloadQueue();
}
}
/**
* Get bundle priority
*/
getBundlePriority(bundle) {
if (bundle.isCritical) {
return index_1.DownloadPriority.CRITICAL;
}
// Calculate average priority of assets in bundle
if (bundle.assets.length === 0) {
return index_1.DownloadPriority.LOW;
}
const totalPriority = bundle.assets.reduce((sum, asset) => sum + asset.priority, 0);
return Math.round(totalPriority / bundle.assets.length);
}
/**
* Find asset by ID across all bundles
*/
findAssetById(assetId) {
if (!this.manifest)
return undefined;
for (const bundle of this.manifest.bundles) {
const asset = bundle.assets.find(a => a.id === assetId);
if (asset)
return asset;
}
return undefined;
}
/**
* Handle network condition changes
*/
async handleNetworkChange(conditions) {
var _a;
this.currentNetworkConditions = conditions;
(_a = this.networkChangeCallback) === null || _a === void 0 ? void 0 : _a.call(this, conditions);
// Adjust concurrent downloads based on new conditions
if (this.processing) {
const newMaxConcurrent = this.strategy.getMaxConcurrentDownloads(conditions);
const currentActive = this.downloadManager.getActiveDownloadCount();
// If network improved, start more downloads
if (newMaxConcurrent > currentActive && this.downloadQueue.size > 0) {
await this.processDownloadQueue();
}
}
}
/**
* Start automatic cleanup
*/
startAutoCleanup() {
setInterval(async () => {
if (this.destroyed)
return;
try {
await this.cleanup();
}
catch (error) {
this.log(`Auto cleanup error: ${error}`);
}
}, 60 * 24 * 60 * 60 * 1000); // Daily cleanup
}
/**
* Log message if logging is enabled
*/
log(message) {
if (this.config.enableLogging) {
console.log(`[ToastAssetManager] ${message}`);
}
}
}
exports.ToastAssetManager = ToastAssetManager;