@kadi.build/local-remote-file-manager-ability
Version:
Local & Remote File Management System with S3-compatible container registry, HTTP server provider, file streaming, and comprehensive testing suite
962 lines (827 loc) • 29 kB
JavaScript
/**
* File Streaming Utilities
*
* Reusable utilities for efficient file streaming with range request support,
* progress tracking, and proper caching headers. Extracted from TunnelProvider
* and enhanced for container registry compatibility.
*
* Features:
* - MIME type detection with extended container formats
* - Range request parsing and validation
* - ETag generation and caching headers
* - Progress tracking with event emission
* - Memory-efficient streaming for large files
* - Error handling and recovery
*/
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import path from 'path';
import crypto from 'crypto';
import { EventEmitter } from 'events';
class FileStreamingUtils extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
// Default streaming options
bufferSize: options.bufferSize || 64 * 1024, // 64KB chunks
progressInterval: options.progressInterval || 1024 * 1024, // 1MB progress updates
enableETag: options.enableETag !== false, // Default true
enableLastModified: options.enableLastModified !== false, // Default true
enableCaching: options.enableCaching !== false, // Default true
maxCacheAge: options.maxCacheAge || 3600, // 1 hour default cache
...options
};
}
// ============================================================================
// MIME TYPE DETECTION (Enhanced for Container Formats)
// ============================================================================
/**
* Get MIME type for file with enhanced container format support
* @param {string} filePath - Path to the file
* @returns {string} MIME type
*/
getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
// Enhanced MIME types including container registry formats
const mimeTypes = {
// Text files
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.xml': 'application/xml',
'.yaml': 'application/x-yaml',
'.yml': 'application/x-yaml',
// JavaScript/JSON
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.json': 'application/json',
'.jsonl': 'application/jsonlines',
// Images
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.tif': 'image/tiff',
// Archives and container formats
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.tgz': 'application/gzip',
'.bz2': 'application/x-bzip2',
'.xz': 'application/x-xz',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
// Container registry specific
'.layer': 'application/vnd.docker.image.rootfs.diff.tar.gzip',
'.manifest': 'application/vnd.docker.distribution.manifest.v2+json',
'.config': 'application/vnd.docker.container.image.v1+json',
// Documents
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Audio/Video
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
'.webm': 'video/webm',
'.mkv': 'video/x-matroska',
// Programming languages
'.py': 'text/x-python',
'.java': 'text/x-java-source',
'.cpp': 'text/x-c++src',
'.c': 'text/x-csrc',
'.h': 'text/x-chdr',
'.sh': 'application/x-sh',
'.bat': 'application/x-bat',
'.ps1': 'application/x-powershell',
// Configuration files
'.ini': 'text/plain',
'.conf': 'text/plain',
'.cfg': 'text/plain',
'.env': 'text/plain',
'.properties': 'text/plain',
'.dockerfile': 'text/plain'
};
return mimeTypes[ext] || 'application/octet-stream';
}
// ============================================================================
// FILE INFORMATION AND METADATA
// ============================================================================
/**
* Get comprehensive file information
* @param {string} filePath - Path to the file
* @returns {Promise<Object>} File information object
*/
async getFileInfo(filePath) {
try {
const stats = await fsPromises.stat(filePath);
const fileName = path.basename(filePath);
const fileExt = path.extname(filePath).toLowerCase();
const mimeType = this.getMimeType(filePath);
return {
filePath,
fileName,
fileExt,
mimeType,
size: stats.size,
mtime: stats.mtime,
ctime: stats.ctime,
atime: stats.atime,
isFile: stats.isFile(),
isDirectory: stats.isDirectory(),
mode: stats.mode,
uid: stats.uid,
gid: stats.gid,
dev: stats.dev,
ino: stats.ino,
nlink: stats.nlink,
// Calculated fields
etag: this.calculateETag(stats),
lastModified: stats.mtime.toUTCString(),
formattedSize: this.formatBytes(stats.size),
// Additional metadata
isLargeFile: stats.size > 100 * 1024 * 1024, // > 100MB
isContainerLayer: this.isContainerLayer(filePath),
supportedRanges: true // Always true for file streaming
};
} catch (error) {
throw new Error(`Failed to get file info for ${filePath}: ${error.message}`);
}
}
/**
* Check if file is a container layer based on path/extension
* @param {string} filePath - Path to check
* @returns {boolean} True if likely a container layer
*/
isContainerLayer(filePath) {
const fileName = path.basename(filePath).toLowerCase();
const isDigestPath = /^[a-f0-9]{64}$/.test(fileName); // SHA256 digest
const isLayerExt = ['.layer', '.tar', '.gz', '.tgz'].includes(path.extname(filePath).toLowerCase());
const hasLayerInPath = filePath.toLowerCase().includes('layer');
const hasDigestInPath = filePath.toLowerCase().includes('sha256');
return isDigestPath || isLayerExt || hasLayerInPath || hasDigestInPath;
}
// ============================================================================
// ETAG AND CACHING UTILITIES
// ============================================================================
/**
* Calculate ETag for file based on size and modification time
* @param {fs.Stats} stats - File stats object
* @returns {string} ETag value
*/
calculateETag(stats) {
if (!this.options.enableETag) return null;
// Use file size and mtime for ETag (similar to Apache/nginx)
const etag = `"${stats.size.toString(16)}-${stats.mtime.getTime().toString(16)}"`;
return etag;
}
/**
* Generate comprehensive response headers for file serving
* @param {Object} fileInfo - File information object
* @param {Object} options - Additional options
* @returns {Object} Headers object
*/
generateResponseHeaders(fileInfo, options = {}) {
const headers = {
'Content-Type': fileInfo.mimeType,
'Content-Length': fileInfo.size.toString(),
'Accept-Ranges': 'bytes'
};
// Add ETag if enabled
if (this.options.enableETag && fileInfo.etag) {
headers['ETag'] = fileInfo.etag;
}
// Add Last-Modified if enabled
if (this.options.enableLastModified && fileInfo.lastModified) {
headers['Last-Modified'] = fileInfo.lastModified;
}
// Add caching headers if enabled
if (this.options.enableCaching) {
headers['Cache-Control'] = `public, max-age=${this.options.maxCacheAge}`;
// Add Expires header (HTTP/1.0 compatibility)
const expires = new Date(Date.now() + this.options.maxCacheAge * 1000);
headers['Expires'] = expires.toUTCString();
} else {
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
headers['Pragma'] = 'no-cache';
headers['Expires'] = '0';
}
// Add additional headers from options
if (options.additionalHeaders) {
Object.assign(headers, options.additionalHeaders);
}
// Content-Disposition for downloads
if (options.forceDownload) {
headers['Content-Disposition'] = `attachment; filename="${fileInfo.fileName}"`;
} else if (options.inlineDisposition !== false) {
headers['Content-Disposition'] = `inline; filename="${fileInfo.fileName}"`;
}
return headers;
}
// ============================================================================
// RANGE REQUEST PROCESSING
// ============================================================================
/**
* Parse HTTP Range header with comprehensive validation
* @param {string} rangeHeader - Range header value
* @param {number} fileSize - Total file size
* @returns {Array<Object>|null} Array of range objects or null if invalid
*/
parseRangeHeader(rangeHeader, fileSize) {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) {
return null;
}
const ranges = [];
const rangeSpec = rangeHeader.replace(/bytes=/, '').split(',');
for (const spec of rangeSpec) {
const range = this.parseRangeSpec(spec.trim(), fileSize);
if (range) {
ranges.push(range);
}
}
return ranges.length > 0 ? ranges : null;
}
/**
* Parse individual range specification
* @param {string} spec - Range specification (e.g., "0-1023", "-500", "1000-")
* @param {number} fileSize - Total file size
* @returns {Object|null} Range object or null if invalid
*/
parseRangeSpec(spec, fileSize) {
const parts = spec.split('-');
if (parts.length !== 2) return null;
let start = parts[0] ? parseInt(parts[0], 10) : null;
let end = parts[1] ? parseInt(parts[1], 10) : null;
// Check for NaN values (invalid input)
if ((parts[0] && isNaN(start)) || (parts[1] && isNaN(end))) {
return null;
}
// Suffix range: -500 (last 500 bytes)
if (start === null && end !== null) {
start = Math.max(0, fileSize - end);
end = fileSize - 1;
}
// Prefix range: 1000- (from byte 1000 to end)
else if (start !== null && end === null) {
end = fileSize - 1;
}
// Full range: 1000-2000
else if (start !== null && end !== null) {
// Validate range
if (start > end || start >= fileSize || end >= fileSize) {
return null;
}
} else {
return null; // Invalid format
}
// Final validation
if (start < 0 || end < 0 || start >= fileSize || end >= fileSize || start > end) {
return null;
}
return {
start,
end,
length: end - start + 1
};
}
/**
* Format Content-Range header value
* @param {number} start - Range start byte
* @param {number} end - Range end byte
* @param {number} total - Total file size
* @returns {string} Content-Range header value
*/
formatContentRange(start, end, total) {
return `bytes ${start}-${end}/${total}`;
}
/**
* Validate range request against file size
* @param {Array<Object>} ranges - Array of range objects
* @param {number} fileSize - Total file size
* @returns {boolean} True if all ranges are valid
*/
validateRanges(ranges, fileSize) {
if (!ranges || ranges.length === 0) return false;
return ranges.every(range => {
return range.start >= 0 &&
range.end < fileSize &&
range.start <= range.end &&
range.length > 0;
});
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Format bytes to human readable format
* @param {number} bytes - Number of bytes
* @param {number} decimals - Number of decimal places
* @returns {string} Formatted size string
*/
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Format duration in milliseconds to human readable format
* @param {number} ms - Duration in milliseconds
* @returns {string} Formatted duration string
*/
formatDuration(ms) {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
return `${(ms / 3600000).toFixed(1)}h`;
}
/**
* Calculate transfer speed
* @param {number} bytes - Number of bytes transferred
* @param {number} timeMs - Time taken in milliseconds
* @returns {number} Speed in bytes per second
*/
calculateSpeed(bytes, timeMs) {
if (timeMs === 0) return 0;
return bytes / (timeMs / 1000);
}
/**
* Generate unique operation ID
* @returns {string} Unique ID
*/
generateOperationId() {
return `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
// ============================================================================
// ENHANCED FILE STREAMING WITH PROGRESS TRACKING
// ============================================================================
/**
* Enhanced file streaming class with progress tracking and error recovery
*/
class FileStreamer extends EventEmitter {
constructor(fileStreamingUtils, options = {}) {
super();
this.utils = fileStreamingUtils;
this.options = {
bufferSize: options.bufferSize || 64 * 1024, // 64KB chunks
progressInterval: options.progressInterval || 1024 * 1024, // 1MB
timeout: options.timeout || 30000, // 30 seconds
retryAttempts: options.retryAttempts || 3,
retryDelay: options.retryDelay || 1000,
...options
};
this.activeStreams = new Map(); // Track active streaming operations
}
/**
* Create enhanced read stream with range support and progress tracking
* @param {string} filePath - Path to file
* @param {Object} options - Streaming options
* @returns {Promise<Object>} Stream information and control object
*/
async createReadStream(filePath, options = {}) {
const operationId = this.utils.generateOperationId();
const fileInfo = await this.utils.getFileInfo(filePath);
// Parse range if provided
let range = null;
if (options.range) {
const ranges = this.utils.parseRangeHeader(options.range, fileInfo.size);
if (ranges && ranges.length > 0) {
range = ranges[0]; // Use first range for now
}
}
// Calculate stream parameters
const start = range ? range.start : 0;
const end = range ? range.end : fileInfo.size - 1;
const streamLength = end - start + 1;
// Create read stream with options
const streamOptions = {
start,
end,
highWaterMark: this.options.bufferSize
};
const readStream = fs.createReadStream(filePath, streamOptions);
// Create stream tracking object
const streamInfo = {
operationId,
filePath,
fileInfo,
range,
streamLength,
bytesTransferred: 0,
startTime: Date.now(),
lastProgressUpdate: 0,
speed: 0,
estimatedTimeRemaining: 0,
completed: false,
error: null,
stream: readStream
};
// Store in active streams
this.activeStreams.set(operationId, streamInfo);
// Set up progress tracking
this.setupProgressTracking(streamInfo, options);
// Set up error handling
this.setupErrorHandling(streamInfo, options);
return {
operationId,
stream: readStream,
fileInfo,
range,
streamLength,
cancel: () => this.cancelStream(operationId),
getProgress: () => this.getStreamProgress(operationId)
};
}
/**
* Set up progress tracking for stream
* @private
*/
setupProgressTracking(streamInfo, options) {
const { operationId, stream, streamLength, fileInfo } = streamInfo;
stream.on('data', (chunk) => {
streamInfo.bytesTransferred += chunk.length;
const now = Date.now();
const elapsed = now - streamInfo.startTime;
// Calculate current speed
if (elapsed > 0) {
streamInfo.speed = streamInfo.bytesTransferred / (elapsed / 1000);
}
// Calculate estimated time remaining
if (streamInfo.speed > 0) {
const remaining = streamLength - streamInfo.bytesTransferred;
streamInfo.estimatedTimeRemaining = (remaining / streamInfo.speed) * 1000;
}
// Emit progress events
const shouldEmitProgress = (
streamInfo.bytesTransferred - streamInfo.lastProgressUpdate >= this.options.progressInterval ||
streamInfo.bytesTransferred === chunk.length || // First chunk
streamInfo.bytesTransferred >= streamLength // Last chunk
);
if (shouldEmitProgress) {
const progress = {
operationId,
filePath: streamInfo.filePath,
fileName: fileInfo.fileName,
bytesTransferred: streamInfo.bytesTransferred,
totalBytes: streamLength,
percentage: Math.round((streamInfo.bytesTransferred / streamLength) * 100),
speed: streamInfo.speed,
speedFormatted: this.utils.formatBytes(streamInfo.speed) + '/s',
estimatedTimeRemaining: streamInfo.estimatedTimeRemaining,
elapsed: elapsed,
isRangeRequest: !!streamInfo.range,
timestamp: new Date()
};
this.emit('progress', progress);
streamInfo.lastProgressUpdate = streamInfo.bytesTransferred;
}
});
stream.on('end', () => {
if (streamInfo.completed) return;
streamInfo.completed = true;
const elapsed = Date.now() - streamInfo.startTime;
const finalSpeed = streamInfo.bytesTransferred / (elapsed / 1000);
const completion = {
operationId,
filePath: streamInfo.filePath,
fileName: fileInfo.fileName,
bytesTransferred: streamInfo.bytesTransferred,
totalBytes: streamLength,
duration: elapsed,
speed: finalSpeed,
speedFormatted: this.utils.formatBytes(finalSpeed) + '/s',
durationFormatted: this.utils.formatDuration(elapsed),
isRangeRequest: !!streamInfo.range,
success: true,
timestamp: new Date()
};
this.emit('complete', completion);
this.activeStreams.delete(operationId);
});
}
/**
* Set up error handling for stream
* @private
*/
setupErrorHandling(streamInfo, options) {
const { operationId, stream } = streamInfo;
stream.on('error', (error) => {
streamInfo.error = error;
streamInfo.completed = true;
const errorInfo = {
operationId,
filePath: streamInfo.filePath,
error: error.message,
errorCode: error.code,
bytesTransferred: streamInfo.bytesTransferred,
totalBytes: streamInfo.streamLength,
elapsed: Date.now() - streamInfo.startTime,
timestamp: new Date()
};
this.emit('error', errorInfo);
this.activeStreams.delete(operationId);
});
// Set up timeout if specified
if (this.options.timeout > 0) {
const timeout = setTimeout(() => {
if (!streamInfo.completed) {
const timeoutError = new Error(`Stream timeout after ${this.options.timeout}ms`);
stream.destroy(timeoutError);
}
}, this.options.timeout);
stream.on('end', () => clearTimeout(timeout));
stream.on('error', () => clearTimeout(timeout));
}
}
/**
* Cancel active stream
* @param {string} operationId - Operation ID to cancel
*/
cancelStream(operationId) {
const streamInfo = this.activeStreams.get(operationId);
if (streamInfo && !streamInfo.completed) {
streamInfo.stream.destroy();
streamInfo.completed = true;
this.emit('cancelled', {
operationId,
filePath: streamInfo.filePath,
bytesTransferred: streamInfo.bytesTransferred,
totalBytes: streamInfo.streamLength,
timestamp: new Date()
});
this.activeStreams.delete(operationId);
}
}
/**
* Get progress of active stream
* @param {string} operationId - Operation ID
* @returns {Object|null} Progress information
*/
getStreamProgress(operationId) {
const streamInfo = this.activeStreams.get(operationId);
if (!streamInfo) return null;
const elapsed = Date.now() - streamInfo.startTime;
return {
operationId,
filePath: streamInfo.filePath,
fileName: streamInfo.fileInfo.fileName,
bytesTransferred: streamInfo.bytesTransferred,
totalBytes: streamInfo.streamLength,
percentage: Math.round((streamInfo.bytesTransferred / streamInfo.streamLength) * 100),
speed: streamInfo.speed,
speedFormatted: this.utils.formatBytes(streamInfo.speed) + '/s',
estimatedTimeRemaining: streamInfo.estimatedTimeRemaining,
elapsed: elapsed,
elapsedFormatted: this.utils.formatDuration(elapsed),
isRangeRequest: !!streamInfo.range,
completed: streamInfo.completed,
timestamp: new Date()
};
}
/**
* List all active streams
* @returns {Array<Object>} Array of active stream progress information
*/
listActiveStreams() {
return Array.from(this.activeStreams.keys()).map(id => this.getStreamProgress(id));
}
/**
* Cancel all active streams
*/
cancelAllStreams() {
const activeIds = Array.from(this.activeStreams.keys());
activeIds.forEach(id => this.cancelStream(id));
}
}
// ============================================================================
// DOWNLOAD PROGRESS TRACKER
// ============================================================================
/**
* Download progress tracking and monitoring class
*/
class DownloadTracker extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
trackingInterval: options.trackingInterval || 1000, // 1 second
historySize: options.historySize || 100, // Keep last 100 download records
enableRealTimeStats: options.enableRealTimeStats !== false,
...options
};
this.downloads = new Map(); // Active downloads
this.history = []; // Completed download history
this.stats = {
totalDownloads: 0,
totalBytes: 0,
totalDuration: 0,
averageSpeed: 0,
activeDownloads: 0,
completedDownloads: 0,
failedDownloads: 0
};
// Start real-time tracking if enabled
if (this.options.enableRealTimeStats) {
this.startRealTimeTracking();
}
}
/**
* Start tracking a download
* @param {Object} downloadInfo - Download information
* @returns {string} Download tracking ID
*/
startTracking(downloadInfo) {
const trackingId = `download_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const download = {
trackingId,
startTime: Date.now(),
...downloadInfo,
bytesTransferred: 0,
speed: 0,
percentage: 0,
status: 'active',
lastUpdate: Date.now()
};
this.downloads.set(trackingId, download);
this.stats.activeDownloads++;
this.emit('downloadStarted', { trackingId, download });
return trackingId;
}
/**
* Update download progress
* @param {string} trackingId - Download tracking ID
* @param {Object} progress - Progress information
*/
updateProgress(trackingId, progress) {
const download = this.downloads.get(trackingId);
if (!download || download.status !== 'active') return;
const now = Date.now();
const elapsed = now - download.startTime;
// Update download information
Object.assign(download, {
bytesTransferred: progress.bytesTransferred || download.bytesTransferred,
totalBytes: progress.totalBytes || download.totalBytes,
speed: progress.speed || (elapsed > 0 ? download.bytesTransferred / (elapsed / 1000) : 0),
percentage: progress.percentage || (download.totalBytes > 0 ?
Math.round((download.bytesTransferred / download.totalBytes) * 100) : 0),
estimatedTimeRemaining: progress.estimatedTimeRemaining,
lastUpdate: now
});
this.emit('downloadProgress', { trackingId, download, progress });
}
/**
* Complete a download
* @param {string} trackingId - Download tracking ID
* @param {Object} completionInfo - Completion information
*/
completeDownload(trackingId, completionInfo = {}) {
const download = this.downloads.get(trackingId);
if (!download) return;
const now = Date.now();
const duration = now - download.startTime;
const finalSpeed = download.bytesTransferred / (duration / 1000);
// Update download status
Object.assign(download, {
status: 'completed',
endTime: now,
duration: duration,
speed: finalSpeed,
percentage: 100,
...completionInfo
});
// Move to history
this.history.unshift(download);
if (this.history.length > this.options.historySize) {
this.history.pop();
}
// Update stats
this.stats.activeDownloads--;
this.stats.completedDownloads++;
this.stats.totalDownloads++;
this.stats.totalBytes += download.bytesTransferred;
this.stats.totalDuration += duration;
this.stats.averageSpeed = this.stats.totalBytes / (this.stats.totalDuration / 1000);
this.downloads.delete(trackingId);
this.emit('downloadCompleted', { trackingId, download });
}
/**
* Fail a download
* @param {string} trackingId - Download tracking ID
* @param {Object} errorInfo - Error information
*/
failDownload(trackingId, errorInfo = {}) {
const download = this.downloads.get(trackingId);
if (!download) return;
const now = Date.now();
const duration = now - download.startTime;
// Update download status
Object.assign(download, {
status: 'failed',
endTime: now,
duration: duration,
error: errorInfo.error || 'Unknown error',
errorCode: errorInfo.errorCode,
...errorInfo
});
// Move to history
this.history.unshift(download);
if (this.history.length > this.options.historySize) {
this.history.pop();
}
// Update stats
this.stats.activeDownloads--;
this.stats.failedDownloads++;
this.downloads.delete(trackingId);
this.emit('downloadFailed', { trackingId, download, error: errorInfo });
}
/**
* Get download information
* @param {string} trackingId - Download tracking ID
* @returns {Object|null} Download information
*/
getDownload(trackingId) {
return this.downloads.get(trackingId) || null;
}
/**
* List active downloads
* @returns {Array<Object>} Array of active downloads
*/
listActiveDownloads() {
return Array.from(this.downloads.values());
}
/**
* Get download history
* @param {number} limit - Maximum number of records to return
* @returns {Array<Object>} Array of historical downloads
*/
getHistory(limit = 50) {
return this.history.slice(0, limit);
}
/**
* Get overall statistics
* @returns {Object} Statistics object
*/
getStats() {
return { ...this.stats };
}
/**
* Start real-time tracking updates
* @private
*/
startRealTimeTracking() {
this.trackingInterval = setInterval(() => {
const now = Date.now();
// Check for stalled downloads (no updates for 30 seconds)
for (const [trackingId, download] of this.downloads.entries()) {
if (now - download.lastUpdate > 30000) {
this.failDownload(trackingId, {
error: 'Download stalled - no progress updates received',
errorCode: 'STALLED'
});
}
}
// Emit real-time stats
this.emit('statsUpdate', this.getStats());
}, this.options.trackingInterval);
}
/**
* Stop real-time tracking
*/
stopRealTimeTracking() {
if (this.trackingInterval) {
clearInterval(this.trackingInterval);
this.trackingInterval = null;
}
}
/**
* Cleanup and shutdown
*/
shutdown() {
this.stopRealTimeTracking();
this.downloads.clear();
this.emit('shutdown');
}
}
export {
FileStreamingUtils,
FileStreamer,
DownloadTracker
};