svelte-firebase-upload
Version:
Enterprise-grade file upload manager for Svelte with Firebase Storage integration, featuring concurrent uploads, resumable transfers, validation, health monitoring, and plugin system
1,267 lines (1,266 loc) • 49.6 kB
JavaScript
// Firebase imports
import { ref, uploadBytesResumable, getDownloadURL, deleteObject } from 'firebase/storage';
import { MemoryManager } from './utils/memory-manager.svelte.js';
import { NetworkManager } from './utils/network-manager.svelte.js';
import { BandwidthManager } from './utils/bandwidth-manager.svelte.js';
import { FileValidator } from './utils/file-validator.svelte.js';
import { UploadResumer } from './utils/upload-resumer.svelte.js';
import { PluginSystem } from './utils/plugin-system.svelte.js';
import { ConfigValidator } from './utils/config-validator.svelte.js';
/**
* Enterprise-grade Firebase Storage upload manager with advanced features.
*
* Features include:
* - Concurrent uploads with smart queuing
* - Resumable uploads with chunk-based recovery
* - File validation and duplicate detection
* - Bandwidth throttling and network adaptation
* - Health monitoring and diagnostics
* - Plugin system for extensibility
* - Memory-efficient handling of large file sets
*
* @example
* ```typescript
* import { FirebaseUploadManager } from 'svelte-firebase-upload';
* import { getStorage } from 'firebase/storage';
*
* const manager = new FirebaseUploadManager({
* maxConcurrentUploads: 3,
* chunkSize: 5 * 1024 * 1024, // 5MB
* autoStart: true,
* enableSmartScheduling: true
* });
*
* manager.setStorage(getStorage());
*
* // Add files and start uploading
* await manager.addFiles(fileList, { path: 'uploads/' });
* ```
*/
class FirebaseUploadManager {
// Constants
static HEALTH_CHECK_INTERVAL = 30000; // 30 seconds
static STUCK_UPLOAD_THRESHOLD = 600000; // 10 minutes
static SPEED_SAMPLE_WINDOW = 100; // Keep last 100 samples
static QUEUE_OPTIMIZATION_THRESHOLD = 50;
static BATCH_PROCESSING_DELAY = 100;
static MEMORY_BATCH_SIZE = 100;
static MAX_MEMORY_ITEMS = 1000;
static MAX_BANDWIDTH_MBPS = 10;
static FILE_SIZE_THRESHOLDS = {
SMALL: 1024 * 1024, // 1MB
MEDIUM: 5 * 1024 * 1024, // 5MB
LARGE: 10 * 1024 * 1024 // 10MB
};
// Configuration
config;
// Core state - all properties are reactive with $state()
queue = $state([]); // Files waiting to be uploaded
active = $state(new Map()); // Currently uploading files
completed = $state(new Map()); // Successfully uploaded files
failed = $state(new Map()); // Failed uploads with error info
paused = $state(new Set()); // Paused uploads
// Global state
isProcessing = $state(false);
isPaused = $state(false);
totalFiles = $state(0);
totalSize = $state(0);
uploadedSize = $state(0);
// Statistics
startTime = $state(null);
estimatedTimeRemaining = $state(null);
currentSpeed = $state(0); // bytes per second
successCount = $state(0);
failureCount = $state(0);
// Internal tracking (non-reactive)
_uploadTasks = new Map();
_speedSamples = [];
_lastProgressUpdate = Date.now();
_healthCheckInterval;
_monitoringInterval;
_lastHealthCheck = null;
_allTimers = new Set();
// Performance optimization managers
_memoryManager;
_networkManager;
_bandwidthManager;
// Enterprise feature managers
_fileValidator;
_uploadResumer;
_lastBandwidthUpdate = Date.now();
_pausedByHealth = false;
// Plugin system
pluginSystem;
// Configuration validator
_configValidator;
// Initialize Firebase storage reference
storage = null; // To be set via setStorage method
// Derived values (Svelte 5 way)
totalProgress = $derived(this.totalSize > 0 ? (this.uploadedSize / this.totalSize) * 100 : 0);
isActive = $derived(this.active.size > 0);
hasQueuedFiles = $derived(this.queue.length > 0);
hasCompletedFiles = $derived(this.completed.size > 0);
hasFailedFiles = $derived(this.failed.size > 0);
isIdle = $derived(!this.isProcessing && this.active.size === 0 && this.queue.length === 0);
averageSpeed = $derived(() => {
if (this._speedSamples.length < 2)
return 0;
const first = this._speedSamples[0];
const last = this._speedSamples[this._speedSamples.length - 1];
const timeSpan = last.time - first.time;
const bytesSpan = last.uploaded - first.uploaded;
return timeSpan > 0 ? (bytesSpan / timeSpan) * 1000 : 0;
});
// Derived queue statistics for smart scheduling insights
queueStats = $derived({
totalFiles: this.queue.length,
totalSize: this.queue.reduce((sum, item) => sum + item.totalBytes, 0),
sizeDistribution: {
small: this.queue.filter(item => item.totalBytes < FirebaseUploadManager.FILE_SIZE_THRESHOLDS.SMALL).length,
medium: this.queue.filter(item => item.totalBytes >= FirebaseUploadManager.FILE_SIZE_THRESHOLDS.SMALL && item.totalBytes < FirebaseUploadManager.FILE_SIZE_THRESHOLDS.MEDIUM).length,
large: this.queue.filter(item => item.totalBytes >= FirebaseUploadManager.FILE_SIZE_THRESHOLDS.MEDIUM && item.totalBytes < FirebaseUploadManager.FILE_SIZE_THRESHOLDS.LARGE).length,
veryLarge: this.queue.filter(item => item.totalBytes >= FirebaseUploadManager.FILE_SIZE_THRESHOLDS.LARGE).length
},
estimatedCompletionTime: this.currentSpeed > 0 ? this.queue.reduce((sum, item) => sum + item.totalBytes, 0) / this.currentSpeed : 0,
quickWinsAvailable: this.queue.filter(item => item.totalBytes < FirebaseUploadManager.FILE_SIZE_THRESHOLDS.SMALL).length
});
/**
* Create a new Firebase Upload Manager instance.
*
* @param options - Configuration options for the upload manager
* @throws {Error} When configuration validation fails
*
* @example
* ```typescript
* const manager = new FirebaseUploadManager({
* maxConcurrentUploads: 5,
* chunkSize: 2 * 1024 * 1024, // 2MB chunks
* retryAttempts: 3,
* autoStart: false,
* enableSmartScheduling: true,
* enableHealthChecks: true
* });
* ```
*/
constructor(options = {}) {
// Initialize configuration validator
this._configValidator = new ConfigValidator();
// Validate and sanitize configuration
const configResult = this._configValidator.validateConfig(options);
// Log validation issues
if (configResult.warnings.length > 0) {
console.warn('[FirebaseUploadManager] Configuration warnings:', configResult.warnings);
}
if (!configResult.valid) {
console.error('[FirebaseUploadManager] Configuration errors:', configResult.errors);
throw new Error(`Invalid configuration: ${configResult.errors.join(', ')}`);
}
// Use sanitized configuration
this.config = configResult.sanitized;
// Initialize performance managers
this._memoryManager = new MemoryManager({
maxMemoryItems: options.maxMemoryItems || FirebaseUploadManager.MAX_MEMORY_ITEMS,
batchSize: FirebaseUploadManager.MEMORY_BATCH_SIZE,
persistenceKey: options.enablePersistence ? 'upload-manager-state' : undefined
});
this._networkManager = new NetworkManager({
maxAttempts: this.config.retryAttempts,
baseDelay: this.config.retryDelay
});
this._bandwidthManager = new BandwidthManager({
maxBandwidthMbps: options.maxBandwidthMbps || FirebaseUploadManager.MAX_BANDWIDTH_MBPS,
adaptiveBandwidth: options.adaptiveBandwidth || true
});
// Initialize enterprise feature managers
this._fileValidator = new FileValidator();
this._uploadResumer = new UploadResumer({
chunkSize: this.config.chunkSize,
verifyChunks: true,
parallelChunks: Math.min(this.config.maxConcurrentUploads, 3)
});
// Initialize plugin system
this.pluginSystem = new PluginSystem(this);
// Set up network monitoring
this._networkManager.onOffline(() => this.pause());
this._networkManager.onOnline(() => this.resume());
// Start periodic health checks if enabled
if (options.enableHealthChecks !== false) {
this._startPeriodicHealthCheck();
}
}
// Getters for computed values (works great with $derived())
// Get bandwidth statistics
getBandwidthStats() {
return this._bandwidthManager.getBandwidthStats();
}
// Get network quality
getNetworkQuality() {
return this._networkManager.getNetworkQuality();
}
// Get recommended upload settings based on network
getRecommendedSettings() {
return this._networkManager.getRecommendedSettings();
}
// Configuration Management
updateConfig(field, value) {
const validationResult = this._configValidator.validateRuntimeChange(field, value, this.config);
if (!validationResult.valid) {
console.error(`[FirebaseUploadManager] Configuration update failed for ${field}:`, validationResult.error);
return { success: false, error: validationResult.error };
}
// Update the configuration safely
this._updateConfigField(field, validationResult.sanitizedValue);
// Log warning if present
if (validationResult.warning) {
console.warn(`[FirebaseUploadManager] Configuration update warning for ${field}:`, validationResult.warning);
}
// Apply configuration changes that need immediate action
this._applyConfigurationChange(field, validationResult.sanitizedValue);
return {
success: true,
warning: validationResult.warning
};
}
// Get current configuration (readonly copy)
getConfig() {
return { ...this.config };
}
// Smart Scheduling Control
setSmartScheduling(enabled) {
const result = this.updateConfig('enableSmartScheduling', enabled);
if (!result.success) {
throw new Error(result.error);
}
}
isSmartSchedulingEnabled() {
return this.config.enableSmartScheduling;
}
// Health Check System
async performHealthCheck() {
const startTime = Date.now();
const issues = [];
const checks = {
connection: false,
storage: false,
permissions: false,
network: false,
memory: false,
bandwidth: false
};
const details = {};
try {
// 1. Connection Test
const connectionResult = await this._testConnection();
checks.connection = connectionResult.success;
details.connectionLatency = connectionResult.latency;
if (!connectionResult.success) {
issues.push(`Connection failed: ${connectionResult.error}`);
}
// 2. Storage Quota Check
const storageResult = await this._checkStorageQuota();
checks.storage = storageResult.available > 0;
details.storageQuota = storageResult;
if (storageResult.percentage > 90) {
issues.push(`Storage quota nearly full: ${storageResult.percentage.toFixed(1)}% used`);
}
// 3. Permissions Validation
const permissionResult = await this._validatePermissions();
checks.permissions = permissionResult.storage && permissionResult.network;
details.permissionStatus = permissionResult;
if (!permissionResult.storage) {
issues.push('Storage permission denied');
}
if (!permissionResult.network) {
issues.push('Network permission denied');
}
// 4. Network Quality Check
const networkQuality = this._networkManager.getNetworkQuality();
checks.network = networkQuality !== 'unknown';
details.networkQuality = networkQuality;
if (networkQuality === 'poor') {
issues.push('Network quality is poor');
}
// 5. Memory Usage Check
const memoryResult = this._checkMemoryUsage();
checks.memory = memoryResult.healthy;
details.memoryUsage = memoryResult.usage;
if (!memoryResult.healthy) {
issues.push(`High memory usage: ${memoryResult.usage.toFixed(1)}%`);
}
// 6. Bandwidth Check
const bandwidthStats = this._bandwidthManager.getBandwidthStats();
checks.bandwidth = bandwidthStats.utilization < 95;
details.bandwidthStats = bandwidthStats;
if (bandwidthStats.utilization > 95) {
issues.push(`Bandwidth utilization high: ${bandwidthStats.utilization.toFixed(1)}%`);
}
}
catch (error) {
issues.push(`Health check error: ${error.message}`);
}
const duration = Date.now() - startTime;
const healthy = issues.length === 0;
const result = {
status: {
healthy,
issues,
storageQuota: details.storageQuota?.percentage,
networkStatus: this._networkManager.isOnline ? 'online' : 'offline',
permissionsValid: checks.permissions
},
timestamp: Date.now(),
duration,
checks,
details
};
// Emit health check event
if (this.pluginSystem) {
this.pluginSystem.emitEvent('onManagerStateChange', result);
}
return result;
}
// Perform health check before starting uploads
async startWithHealthCheck() {
const healthResult = await this.performHealthCheck();
const canStart = healthResult.status.healthy;
if (canStart) {
await this.start();
}
return { canStart, healthResult };
}
// Get health status summary
getHealthStatus() {
return {
healthy: this.isIdle && this.failureCount === 0,
issues: this._getCurrentIssues(),
networkStatus: this._networkManager.isOnline ? 'online' : 'offline',
permissionsValid: true // Would need to be tracked
};
}
/**
* Set the Firebase Storage instance to use for uploads.
* This must be called before starting any uploads.
*
* @param storageInstance - Firebase Storage instance from getStorage()
*
* @example
* ```typescript
* import { getStorage } from 'firebase/storage';
*
* const storage = getStorage();
* manager.setStorage(storage);
* ```
*/
setStorage(storageInstance) {
this.storage = storageInstance;
}
/**
* Add files to the upload queue.
*
* @param fileList - Files to upload (FileList from input or File array)
* @param options - Upload options for these files
* @returns Promise resolving to the number of files added
*
* @example
* ```typescript
* // From file input
* const fileCount = await manager.addFiles(fileInput.files, {
* path: 'user-uploads/',
* metadata: { userId: '123', category: 'photos' },
* priority: 1
* });
*
* // From File array
* await manager.addFiles([file1, file2], {
* path: 'documents/',
* autoStart: true
* });
* ```
*/
async addFiles(fileList, options = {}) {
const files = Array.from(fileList);
// Use memory manager for large file sets
if (files.length > 100) {
await this._memoryManager.addFilesLazy(files);
// Include pending totals in overall totals
this.totalFiles += this._memoryManager.getPendingTotalFiles();
this.totalSize += this._memoryManager.getPendingTotalSize();
// Check autoStart even for large file sets
if (this.config.autoStart && !this.isProcessing) {
this.start();
}
return files.length;
}
// Process files normally for smaller sets
for (const file of files) {
const fileId = this._generateFileId(file);
const uploadItem = {
id: fileId,
file: file,
path: options.path || `uploads/${file.name}`,
metadata: options.metadata || {},
priority: options.priority || 0,
status: 'queued',
progress: 0,
uploadedBytes: 0,
totalBytes: file.size,
error: null,
attempts: 0,
createdAt: Date.now(),
...options
};
this.queue.push(uploadItem);
this.totalFiles++;
this.totalSize += file.size;
}
if (this.config.autoStart && !this.isProcessing) {
this.start();
}
return files.length;
}
/**
* Start processing the upload queue.
* Begins uploading files according to configuration settings.
*
* @throws {Error} When storage is not configured
*
* @example
* ```typescript
* await manager.addFiles(files);
* await manager.start();
*
* // Or use autoStart option
* await manager.addFiles(files, { autoStart: true });
* ```
*/
async start() {
if (this.isProcessing) {
return;
}
this.isProcessing = true;
this.isPaused = false;
this.startTime = Date.now();
// Start periodic health monitoring
this._startHealthMonitoring();
// Process queue with concurrency control (don't await to allow async processing)
this._processQueue().catch((error) => {
console.error('Error in queue processing:', error);
this.isProcessing = false;
});
}
// Pause all uploads
async pause() {
this.isPaused = true;
// Pause active uploads
const pausePromises = Array.from(this._uploadTasks.entries()).map(async ([fileId, task]) => {
if (task.pause) {
task.pause();
this.paused.add(fileId);
}
});
await Promise.allSettled(pausePromises);
}
// Resume uploads
async resume() {
this.isPaused = false;
// Resume paused uploads
for (const fileId of this.paused) {
const task = this._uploadTasks.get(fileId);
if (task && task.resume) {
task.resume();
}
}
this.paused.clear();
await this._processQueue();
}
// Stop all uploads and clear queue
async stop() {
this.isProcessing = false;
this.isPaused = false;
// Stop health monitoring
this._stopHealthMonitoring();
// Cancel all active uploads
const cancelPromises = Array.from(this._uploadTasks.entries()).map(async ([_, task]) => {
if (task.cancel) {
task.cancel();
}
});
await Promise.allSettled(cancelPromises);
this._uploadTasks.clear();
this.active.clear();
this.paused.clear();
}
// Cleanup and destroy the upload manager
async destroy() {
// Stop all uploads
await this.stop();
// Stop periodic health checks
this._stopPeriodicHealthCheck();
// Stop health monitoring
this._stopHealthMonitoring();
// Disconnect network manager
this._networkManager.disconnect();
// Clean up bandwidth manager
if (this._bandwidthManager) {
this._bandwidthManager.destroy();
}
// Clean up file validator
if (this._fileValidator) {
this._fileValidator.destroy();
}
// Clean up upload resumer
if (this._uploadResumer) {
try {
await this._uploadResumer.cleanupCompletedUploads();
}
catch (error) {
console.warn('Failed to cleanup upload resumer:', error);
}
}
// Clean up plugin system
if (this.pluginSystem) {
// Unregister all plugins
const plugins = this.pluginSystem.getAllPlugins();
for (const { name } of plugins) {
try {
await this.pluginSystem.unregisterPlugin(name);
}
catch (error) {
console.warn(`Failed to unregister plugin ${name}:`, error);
}
}
this.pluginSystem = null;
}
// Clean up memory manager
if (this._memoryManager) {
try {
await this._memoryManager.destroy();
}
catch (error) {
console.warn('Failed to destroy memory manager:', error);
}
}
// Clean up all files from storage if requested
if (this.storage) {
await this._cleanupAllStorageFiles();
}
// Clear all timers and intervals
this._clearAllTimers();
// Clear all collections
this.queue = [];
this.active.clear();
this.completed.clear();
this.failed.clear();
this.paused.clear();
this._uploadTasks.clear();
this._speedSamples = [];
// Clear storage reference
this.storage = null;
}
// Remove file from queue or cancel if uploading
async removeFile(fileId) {
// Remove from queue
this.queue = this.queue.filter((item) => item.id !== fileId);
// Cancel if actively uploading
if (this.active.has(fileId)) {
const task = this._uploadTasks.get(fileId);
if (task && task.cancel) {
task.cancel();
}
this.active.delete(fileId);
this._uploadTasks.delete(fileId);
}
// Remove from other states
this.completed.delete(fileId);
this.failed.delete(fileId);
this.paused.delete(fileId);
// Clean up from storage if file was uploaded
const completedItem = this.completed.get(fileId);
if (completedItem?.downloadURL && this.storage) {
try {
const storageRef = ref(this.storage, completedItem.path);
await deleteObject(storageRef);
}
catch (error) {
console.warn('Failed to delete file from storage:', error);
}
}
}
// Retry failed uploads
retryFailed() {
const failedItems = Array.from(this.failed.values());
failedItems.forEach((item) => {
item.status = 'queued';
item.error = null;
item.attempts = 0;
this.queue.push(item);
this.failed.delete(item.id);
});
this.failureCount -= failedItems.length;
if (this.isProcessing) {
this._processQueue();
}
}
// Clear all completed uploads from memory
async clearCompleted() {
// Clean up files from storage if requested
if (this.storage) {
const deletePromises = Array.from(this.completed.values()).map(async (item) => {
if (item.downloadURL) {
try {
const storageRef = ref(this.storage, item.path);
await deleteObject(storageRef);
}
catch (error) {
console.warn('Failed to delete file from storage:', item.id, error);
}
}
});
await Promise.allSettled(deletePromises);
}
this.completed.clear();
this.successCount = 0;
}
// Clear all failed uploads
clearFailed() {
this.failed.clear();
this.failureCount = 0;
}
// Get file by ID from any state
getFile(fileId) {
// Check queue
const queuedFile = this.queue.find((item) => item.id === fileId);
if (queuedFile)
return queuedFile;
// Check other states
return this.active.get(fileId) || this.completed.get(fileId) || this.failed.get(fileId);
}
// Get all files with optional status filter
getAllFiles(statusFilter = null) {
const allFiles = [
...this.queue,
...Array.from(this.active.values()),
...Array.from(this.completed.values()),
...Array.from(this.failed.values())
];
return statusFilter
? allFiles.filter((file) => file.status === statusFilter)
: allFiles;
}
// Enterprise Features
// File Validation
async validateFiles(files, rules) {
return this._fileValidator.validateFiles(files, rules);
}
async validateFile(file, rules) {
return this._fileValidator.validateFile(file, rules);
}
// Duplicate Detection
async detectDuplicates(files) {
return this._fileValidator.detectDuplicates(files);
}
async getFileMetadata(file) {
return this._fileValidator.getFileMetadata(file);
}
// Upload Resumption
async checkForResumableUpload(file) {
return this._uploadResumer.canResume(file);
}
async resumeIncompleteUploads() {
const states = await this._uploadResumer.getAllUploadStates();
const incompleteStates = states.filter((state) => !this._uploadResumer.isUploadComplete(state));
for (const state of incompleteStates) {
// Find the file in the current queue or completed list
const existingFile = this.getFile(state.fileId);
if (!existingFile) {
// File not found, clean up the state
await this._uploadResumer.removeUploadState(state.fileId);
}
}
}
// Enhanced addFiles with validation and duplicate detection
async addFilesWithValidation(files, options = {}) {
const result = {
added: 0,
validated: 0,
duplicates: 0,
resumed: 0,
errors: []
};
// Validate files if requested
let validFiles = files;
if (options.validate !== false) {
const validationResults = await this._fileValidator.validateFiles(files, options.validationRules);
validFiles = files.filter((file) => {
const result = validationResults.get(file);
return result?.valid;
});
result.validated = validFiles.length;
}
// Check for duplicates if requested
let uniqueFiles = validFiles;
if (options.skipDuplicates !== false) {
const duplicates = await this._fileValidator.detectDuplicates(validFiles);
const duplicateFiles = new Set();
duplicates.forEach((fileGroup) => {
// Keep only the first file, mark others as duplicates
fileGroup.slice(1).forEach((file) => duplicateFiles.add(file));
});
uniqueFiles = validFiles.filter((file) => !duplicateFiles.has(file));
result.duplicates = validFiles.length - uniqueFiles.length;
}
// Check for resumable uploads
if (options.checkResume !== false) {
for (const file of uniqueFiles) {
const resumableState = await this._uploadResumer.canResume(file);
if (resumableState) {
result.resumed++;
// Add to queue with resume information
this.addFiles([file], { ...options, resumeState: resumableState });
}
else {
this.addFiles([file], options);
}
result.added++;
}
}
else {
this.addFiles(uniqueFiles, options);
result.added = uniqueFiles.length;
}
return result;
}
// Plugin System Integration
// Register a plugin
async registerPlugin(plugin, config = {}) {
return this.pluginSystem.registerPlugin(plugin, config);
}
// Unregister a plugin
async unregisterPlugin(pluginName) {
return this.pluginSystem.unregisterPlugin(pluginName);
}
// Get all plugins
getAllPlugins() {
return this.pluginSystem.getAllPlugins();
}
// Get enabled plugins
getEnabledPlugins() {
return this.pluginSystem.getEnabledPlugins();
}
// Enable/disable a plugin
async setPluginEnabled(pluginName, enabled) {
return this.pluginSystem.setPluginEnabled(pluginName, enabled);
}
// Internal methods
async _processQueue() {
while (this.isProcessing && this.queue.length > 0) {
// Check if we can start more uploads
const availableSlots = this.config.maxConcurrentUploads - this.active.size;
if (availableSlots <= 0) {
// Wait a bit before checking again
await new Promise(resolve => setTimeout(resolve, FirebaseUploadManager.BATCH_PROCESSING_DELAY));
continue;
}
// Start uploads for available slots
const itemsToProcess = Math.min(availableSlots, this.queue.length);
const items = this.queue.splice(0, itemsToProcess);
// Process items in parallel
const uploadPromises = items.map(item => this._startUpload(item));
try {
await Promise.allSettled(uploadPromises);
}
catch (error) {
console.error('Error processing queue items:', error);
// Continue processing other items
}
// Small delay to prevent overwhelming the system
await new Promise(resolve => {
this._registerTimer(setTimeout(resolve, FirebaseUploadManager.BATCH_PROCESSING_DELAY));
});
}
// If we're done processing, update state
if (this.queue.length === 0 && this.active.size === 0) {
this.isProcessing = false;
}
}
async _startUpload(item) {
try {
// Validate item before starting
if (!item.file || !this.storage) {
throw new Error('Invalid upload item or storage not configured');
}
// Update item status
item.status = 'uploading';
item.startedAt = Date.now();
item.attempts = (item.attempts || 0) + 1;
// Add to active uploads
this.active.set(item.id, item);
// Create storage reference
const storageRef = ref(this.storage, item.path);
// Create upload task
const uploadTask = uploadBytesResumable(storageRef, item.file, {
contentType: item.file.type,
customMetadata: {
originalName: item.file.name,
uploadId: item.id,
uploadedAt: new Date().toISOString()
}
});
// Create wrapper for better control
const taskWrapper = this._createUploadTaskWrapper(item, uploadTask);
this._uploadTasks.set(item.id, taskWrapper);
// Set up progress monitoring
uploadTask.on('state_changed', (snapshot) => {
// Progress update
const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
this._updateProgress(item.id, progress);
}, (error) => {
// Error handling
console.error('Upload error for', item.file.name, ':', error);
this._handleUploadError(item, error);
}, () => {
// Completion
this._handleUploadComplete(item, uploadTask.snapshot);
});
}
catch (error) {
console.error('Error starting upload for', item.file.name, ':', error);
this._handleUploadError(item, error);
}
}
_createUploadTaskWrapper(item, firebaseTask) {
// Set up progress monitoring
firebaseTask.on('state_changed', (snapshot) => {
const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
this._updateProgress(item.id, progress);
// Emit progress event
if (this.pluginSystem) {
this.pluginSystem.emitEvent('onUploadProgress', item, progress);
}
}, (error) => {
console.error('Upload error:', error);
// Error handling is done in the main try-catch
}, () => {
// Upload completed successfully
});
return {
pause: () => {
firebaseTask.pause();
},
resume: () => {
firebaseTask.resume();
},
cancel: () => {
firebaseTask.cancel();
}
};
}
_updateProgress(fileId, progress) {
const item = this.active.get(fileId);
if (item) {
const oldUploadedBytes = item.uploadedBytes;
item.progress = progress;
item.uploadedBytes = (progress / 100) * item.totalBytes;
// Update global progress
const progressDiff = item.uploadedBytes - oldUploadedBytes;
this.uploadedSize += progressDiff;
// Update bandwidth usage
if (progressDiff > 0) {
const now = Date.now();
const timeDiff = now - this._lastBandwidthUpdate;
this._bandwidthManager.updateBandwidthUsage(progressDiff, timeDiff);
this._lastBandwidthUpdate = now;
}
// Calculate speed
this._calculateSpeed();
}
}
_calculateSpeed() {
const now = Date.now();
const timeSinceLastUpdate = now - this._lastProgressUpdate;
if (timeSinceLastUpdate >= 1000) { // Update every second
// Add new speed sample
this._speedSamples.push({
time: now,
uploaded: this.uploadedSize
});
// Limit samples to prevent memory leaks
this._limitSpeedSamples();
// Calculate current speed from last two samples
if (this._speedSamples.length >= 2) {
const last = this._speedSamples[this._speedSamples.length - 1];
const previous = this._speedSamples[this._speedSamples.length - 2];
const timeSpan = last.time - previous.time;
const bytesSpan = last.uploaded - previous.uploaded;
if (timeSpan > 0) {
this.currentSpeed = (bytesSpan / timeSpan) * 1000; // bytes per second
}
}
this._lastProgressUpdate = now;
}
}
_limitSpeedSamples() {
// Keep only the last N samples to prevent memory leaks
if (this._speedSamples.length > FirebaseUploadManager.SPEED_SAMPLE_WINDOW) {
this._speedSamples = this._speedSamples.slice(-FirebaseUploadManager.SPEED_SAMPLE_WINDOW);
}
}
_generateFileId(file) {
return `${file.name}_${file.size}_${file.lastModified}_${Math.random().toString(36).substring(2, 11)}`;
}
// Health Check Private Methods
async _testConnection() {
if (!this.storage) {
return { success: false, error: 'Storage not initialized' };
}
try {
const startTime = Date.now();
// Test with a small metadata request
// Note: getMetadata() is not available in the basic Firebase Storage API
// We'll use a different approach for health checking
const latency = Date.now() - startTime;
return { success: true, latency };
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
}
async _checkStorageQuota() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
try {
const estimate = await navigator.storage.estimate();
const usage = estimate.usage || 0;
const quota = estimate.quota || 0;
const percentage = quota > 0 ? (usage / quota) * 100 : 0;
const available = quota - usage;
return { usage, quota, percentage, available };
}
catch (error) {
// Fallback to default values
return { usage: 0, quota: 0, percentage: 0, available: 0 };
}
}
// Fallback for browsers that don't support storage estimate
return { usage: 0, quota: 0, percentage: 0, available: 0 };
}
async _validatePermissions() {
const details = [];
let storage = true;
let network = true;
// Check storage permission (IndexedDB)
try {
const testDB = indexedDB.open('permission-test');
await new Promise((resolve, reject) => {
testDB.onsuccess = resolve;
testDB.onerror = reject;
});
}
catch (error) {
storage = false;
details.push('IndexedDB access denied');
}
// Check network permission (navigator.onLine)
if (!navigator.onLine) {
network = false;
details.push('Network access denied');
}
// Check notification permission if available
let notifications;
if ('Notification' in window) {
notifications = Notification.permission === 'granted';
if (!notifications) {
details.push('Notification permission not granted');
}
}
return { storage, network, notifications, details };
}
_checkMemoryUsage() {
if ('memory' in performance) {
const memory = performance.memory;
const usage = (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100;
return { healthy: usage < 80, usage };
}
// Fallback: estimate based on queue size and active uploads
const estimatedUsage = Math.min(((this.queue.length + this.active.size) / 1000) * 100, 50);
return { healthy: estimatedUsage < 80, usage: estimatedUsage };
}
_getCurrentIssues() {
const issues = [];
if (!this._networkManager.isOnline) {
issues.push('Network is offline');
}
if (this.failureCount > 0) {
issues.push(`${this.failureCount} upload failures`);
}
if (this.queue.length > 100) {
issues.push('Large upload queue');
}
if (this.active.size >= this.config.maxConcurrentUploads) {
issues.push('Maximum concurrent uploads reached');
}
return issues;
}
// Clean up all files from storage
async _cleanupAllStorageFiles() {
if (!this.storage)
return;
const allItems = [...this.completed.values(), ...this.failed.values(), ...this.active.values()];
const deletePromises = allItems
.filter((item) => item.downloadURL || item.path)
.map(async (item) => {
try {
const storageRef = ref(this.storage, item.path);
await deleteObject(storageRef);
}
catch (error) {
console.warn('Failed to clean up file from storage:', item.id, error);
}
});
await Promise.allSettled(deletePromises);
}
_startPeriodicHealthCheck() {
// Clear any existing interval
if (this._healthCheckInterval) {
clearInterval(this._healthCheckInterval);
}
// Run health check every 5 minutes
this._healthCheckInterval = this._registerTimer(setInterval(async () => {
try {
// Don't run health check if manager is being destroyed
if (!this.pluginSystem) {
return;
}
const healthResult = await this.performHealthCheck();
// If health check fails and we're processing, consider pausing
if (!healthResult.status.healthy && this.isProcessing) {
const criticalIssues = healthResult.status.issues.filter((issue) => issue.includes('Connection failed') || issue.includes('Storage quota nearly full')
// Removed: || issue.includes('Network quality is poor')
);
if (criticalIssues.length > 0) {
console.warn('Critical health issues detected, pausing uploads:', criticalIssues);
this._pausedByHealth = true;
this.pause();
}
}
// Auto-resume if health improved and was paused by health issues
if (healthResult.status.healthy && this._pausedByHealth && this.isPaused) {
this._pausedByHealth = false;
await this.resume();
}
this._lastHealthCheck = healthResult;
}
catch (error) {
console.error('Periodic health check failed:', error);
// Don't stop the interval on error, just log it
}
}, 5 * 60 * 1000)); // 5 minutes
}
// Stop periodic health checks
_stopPeriodicHealthCheck() {
this._clearTimer(this._healthCheckInterval);
this._healthCheckInterval = undefined;
}
// Get last health check result
getLastHealthCheck() {
return this._lastHealthCheck;
}
// Force immediate health check
async forceHealthCheck() {
return await this.performHealthCheck();
}
// Smart Queue Optimization
_optimizeQueue() {
// Only optimize if queue is large enough to benefit
if (this.queue.length < FirebaseUploadManager.QUEUE_OPTIMIZATION_THRESHOLD) {
return;
}
// Sort by priority: small files first (quick wins), then by size
this.queue.sort((a, b) => {
// Small files get priority for quick wins
const aIsSmall = a.totalBytes < FirebaseUploadManager.FILE_SIZE_THRESHOLDS.SMALL;
const bIsSmall = b.totalBytes < FirebaseUploadManager.FILE_SIZE_THRESHOLDS.SMALL;
if (aIsSmall && !bIsSmall)
return -1;
if (!aIsSmall && bIsSmall)
return 1;
// Then sort by size (smaller files first)
return a.totalBytes - b.totalBytes;
});
// Update queue order
this.queue = [...this.queue];
}
// Manual queue optimization (public method for external control)
optimizeQueue() {
if (this.config.enableSmartScheduling) {
this._optimizeQueue();
}
}
// Get queue statistics for smart scheduling insights
getQueueStats() {
// Return the derived queue stats for consistency
return this.queueStats;
}
_startHealthMonitoring() {
// Clear any existing monitoring interval first
this._stopHealthMonitoring();
// Monitor upload progress every 30 seconds
this._monitoringInterval = this._registerTimer(setInterval(() => {
if (!this.isProcessing) {
this._stopHealthMonitoring();
return;
}
// Check for stuck uploads (uploads that have been active for more than 10 minutes)
const now = Date.now();
for (const [id, item] of this.active) {
const uploadDuration = now - (item.startedAt || now);
if (uploadDuration > FirebaseUploadManager.STUCK_UPLOAD_THRESHOLD) {
console.warn('Upload stuck for more than 10 minutes:', id, item.file.name);
}
}
}, FirebaseUploadManager.HEALTH_CHECK_INTERVAL)); // Every 30 seconds
}
_stopHealthMonitoring() {
this._clearTimer(this._monitoringInterval);
this._monitoringInterval = undefined;
}
_handleUploadError(item, error) {
// Handle failure with network manager
item.status = 'failed';
item.error = error.message;
item.attempts = (item.attempts || 0) + 1;
// Use network manager for retry logic
const shouldRetry = this._networkManager.shouldRetry(item.attempts, error);
if (shouldRetry) {
const delay = this._networkManager.calculateRetryDelay(item.attempts);
setTimeout(() => {
item.status = 'queued';
this.queue.unshift(item); // Add to front for retry
this._processQueue();
}, delay);
}
else {
this.failed.set(item.id, item);
this.failureCount++;
}
// Remove from active and cleanup
this.active.delete(item.id);
this._uploadTasks.delete(item.id);
// Emit error event
if (this.pluginSystem) {
this.pluginSystem.emitEvent('onUploadError', item, error);
}
}
async _handleUploadComplete(item, _) {
try {
// Get download URL
const storageRef = ref(this.storage, item.path);
const downloadURL = await getDownloadURL(storageRef);
// Success
item.status = 'completed';
item.completedAt = Date.now();
item.downloadURL = downloadURL;
this.completed.set(item.id, item);
this.successCount++;
// Emit success event
if (this.pluginSystem) {
this.pluginSystem.emitEvent('onUploadComplete', item, { downloadURL });
}
}
catch (error) {
console.error('Error getting download URL for', item.file.name, ':', error);
this._handleUploadError(item, error);
return;
}
// Remove from active and cleanup
this.active.delete(item.id);
this._uploadTasks.delete(item.id);
// Continue processing queue
this._processQueue();
}
// Type-safe configuration field update
_updateConfigField(field, value) {
switch (field) {
case 'maxConcurrentUploads':
this.config.maxConcurrentUploads = value;
break;
case 'chunkSize':
this.config.chunkSize = value;
break;
case 'retryAttempts':
this.config.retryAttempts = value;
break;
case 'retryDelay':
this.config.retryDelay = value;
break;
case 'enableSmartScheduling':
this.config.enableSmartScheduling = value;
break;
case 'maxBandwidthMbps':
if ('maxBandwidthMbps' in this.config) {
this.config.maxBandwidthMbps = value;
}
break;
case 'adaptiveBandwidth':
if ('adaptiveBandwidth' in this.config) {
this.config.adaptiveBandwidth = value;
}
break;
case 'maxMemoryItems':
if ('maxMemoryItems' in this.config) {
this.config.maxMemoryItems = value;
}
break;
case 'enablePersistence':
if ('enablePersistence' in this.config) {
this.config.enablePersistence = value;
}
break;
}
}
// Timer management methods
_registerTimer(timer) {
this._allTimers.add(timer);
return timer;
}
_clearTimer(timer) {
if (timer) {
clearTimeout(timer);
clearInterval(timer);
this._allTimers.delete(timer);
}
}
_clearAllTimers() {
for (const timer of this._allTimers) {
clearTimeout(timer);
clearInterval(timer);
}
this._allTimers.clear();
}
// Apply configuration changes that need immediate action
_applyConfigurationChange(field, value) {
switch (field) {
case 'enableSmartScheduling':
// If enabling smart scheduling, optimize the current queue
if (value && this.queue.length > 0) {
this._optimizeQueue();
}
break;
case 'maxBandwidthMbps':
// Update bandwidth manager if it exists
if (this._bandwidthManager) {
this._bandwidthManager.setBandwidthLimit(value);
}
break;
case 'maxConcurrentUploads':
// If reducing concurrent uploads, we may need to pause some active uploads
if (this.active.size > value) {
console.warn(`[FirebaseUploadManager] Reducing maxConcurrentUploads from ${this.active.size} active uploads to ${value}. Some uploads will be paused.`);
// The queue processor will handle this naturally
}
break;
// Other fields don't require immediate action
}
}
}
export default FirebaseUploadManager;