UNPKG

@samluvanda/uploadx

Version:

Modern, lightweight, and extensible JavaScript file uploader supporting chunked uploads, filtering, retry logic, progress tracking, and event hooks.

987 lines (851 loc) 28 kB
import { File } from './core/File.js'; import { QueueProgress } from './core/QueueProgress.js'; export class Uploader { static ERROR_CODES = { GENERIC: 1000, HTTP: 2000, IO: 3000, SECURITY: 4000, INIT: 5000, CONFIG: 5100, FILE_SIZE: 6000, FILE_EXTENSION: 7000, FILE_DUPLICATE: 8000, MEMORY: 9000, }; static STATES = { STARTED: 'started', STOPPED: 'stopped', UPLOADING: 'uploading', QUEUED: 'queued', FAILED: 'failed', DONE: 'done' }; #defaults = { browse_button: null, url: '', filters: { mime_types: [], max_file_size: 0, prevent_duplicates: false, }, headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, max_retries: 0, chunk_size: 0, multi_selection: true, unique_names: false, file_data_name: 'file', container: null, http_method: 'POST', send_chunk_number: true, send_file_name: true, }; #events = {}; #destroyed = false; #fileInput = null; #maxFileSize = 0; #browseButtonEl = null; #chunkSize = 0; #containerEl = null; #extensions = []; #browseClickHandler = null; /** * Constructs the Uploader instance with provided settings. * @param {Object} options - Uploader configuration */ constructor(options = {}) { this.options = structuredClone(this.#defaults); this.state = Uploader.STATES.STOPPED; this.files = []; this.total = new QueueProgress(); this.startTime = null; this.disabled = false; this.xhr = null; this.bindEventListeners(); this.setOption(options); } /** * Initializes the file input element and binds UI interactions. * - Creates and configures a hidden <input type="file">. * - Applies allowed file extensions and multi-select settings. * - Binds click handler to the browse button to trigger file input. * - Binds change handler to add selected files to the queue. * @private */ #init() { if (this.options === null) return; // Remove file input if present this.#removeFileInput(); // Unbind previous click handler if it exists if (this.#browseClickHandler) { this.#browseButtonEl.removeEventListener('click', this.#browseClickHandler); } // Create file input this.#fileInput = document.createElement('input'); this.#fileInput.type = 'file'; this.#fileInput.style.display = 'none'; // Apply file extension filters if (this.#extensions.length) { this.#fileInput.accept = this.#extensions.map(ext => '.' + ext).join(','); } // Multi-selection this.#fileInput.multiple = !!this.options.multi_selection; // Append to container or after browse button if (this.#containerEl) { this.#containerEl.appendChild(this.#fileInput); } else { this.#browseButtonEl.parentNode.insertBefore(this.#fileInput, this.#browseButtonEl.nextSibling); } // Define and bind new click handler this.#browseClickHandler = () => { if (!this.disabled) { this.#fileInput.click(); } }; this.#browseButtonEl.addEventListener('click', this.#browseClickHandler); // On file selected this.#fileInput.addEventListener('change', (e) => { this.addFile(e.target); this.#fileInput.value = ''; // Reset for next selection }); } /** * Register an event listener. * @param {string} name - Event name * @param {Function} fn - Callback function * @param {Object} [scope] - Scope to bind * @param {number} [priority=0] - Not used yet */ bind(name, fn, scope = null, priority = 0) { if (!this.#events[name]) this.#events[name] = []; this.#events[name].push({ fn, scope, priority }); } /** * Unregister a specific event listener. * @param {string} name - Event name * @param {Function} fn - Callback to remove */ unbind(name, fn) { if (!this.#events[name]) return; this.#events[name] = this.#events[name].filter(e => e.fn !== fn); } /** * Removes all event listeners for all events. */ unbindAll() { this.#events = {}; } /** * Dispatches the specified event name and its arguments to all listeners. * * @param {string} name - Event name to trigger * @param {...any} args - Arguments to pass to listener functions */ trigger(name, ...args) { if (!this.#events[name]) return; for (const { fn, scope } of this.#events[name]) { const result = fn.apply(scope || this, args); if (result === false) return false; // allow early cancel } return true; // default to true } /** * Update uploader options * @param {string|Object} option - Option key or object map * @param {*} [value] - Option value */ setOption(option, value) { if (typeof option === 'string') { this.options[option] = value; } else if (typeof option === 'object') { this.options = { ...this.options, ...option }; } this.#validateOptions(); } /** * Get the value of a specific option or all * @param {string} [option] - Option name * @returns {*} Value or full options */ getOption(option) { return option ? this.options[option] : this.options; } /** * Validates all settings and triggers an Error event if misconfigured. */ #validateOptions() { const { browse_button, url, filters, headers, max_retries, chunk_size, multi_selection, unique_names, file_data_name, container, http_method, send_chunk_number, send_file_name } = this.options; // Validate browse_button let browseEl = null; if (typeof browse_button === 'string') { browseEl = document.getElementById(browse_button); } else if (browse_button instanceof HTMLElement) { browseEl = browse_button; } if (!browseEl) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: 'Invalid or missing "browse_button" element.' }); return; } this.#browseButtonEl = browseEl; // Validate URL if (typeof url !== 'string' || url.trim() === '') { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: 'Required setting "url" must be a non-empty string.' }); return; } // Validate filters if (typeof filters !== 'object' || filters === null || Array.isArray(filters)) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: '"filters" must be a plain object.' }); } else { // Validate max_file_size if ('max_file_size' in filters) { const parsedMax = this.#parseSize(filters.max_file_size); if (isNaN(parsedMax) || parsedMax < 0) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: '"filters.max_file_size" must be a valid size string or number.' }); } else { this.#maxFileSize = parsedMax; } } // Validate mime_types if ('mime_types' in filters) { if (!Array.isArray(filters.mime_types)) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: '"filters.mime_types" must be an array.' }); } else { const allExtensions = new Set(); for (const [index, filter] of filters.mime_types.entries()) { if ( typeof filter !== 'object' || typeof filter.title !== 'string' || typeof filter.extensions !== 'string' ) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: `mime_types[${index}] must contain "title" (string) and "extensions" (comma-separated string).` }); break; } const extensions = filter.extensions.split(',').map(e => e.trim().toLowerCase()); if (!extensions.length) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: `mime_types[${index}] contains no valid extensions.` }); break; } extensions.forEach(ext => allExtensions.add(ext)); } this.#extensions = Array.from(allExtensions); } } // Validate prevent_duplicates if ('prevent_duplicates' in filters && typeof filters.prevent_duplicates !== 'boolean') { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: '"filters.prevent_duplicates" must be a boolean.' }); } } // Validate headers if (typeof headers !== 'object' || Array.isArray(headers)) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: '"headers" must be a key-value object.' }); } // Validate chunk_size const parsedChunkSize = this.#parseSize(chunk_size); if (isNaN(parsedChunkSize) || parsedChunkSize < 0) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: '"chunk_size" must be a non-negative number or size string like "200kb", "1mb", etc.' }); } else { this.#chunkSize = parsedChunkSize; } // Validate numeric options if (typeof max_retries !== 'number' || max_retries < 0) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: '"max_retries" must be a non-negative number.' }); } // Validate boolean options const booleanFields = [ { key: 'multi_selection', value: multi_selection }, { key: 'unique_names', value: unique_names }, { key: 'send_chunk_number', value: send_chunk_number }, { key: 'send_file_name', value: send_file_name } ]; booleanFields.forEach(({ key, value }) => { if (typeof value !== 'boolean') { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: `"${key}" must be a boolean.` }); } }); // Validate file_data_name if (typeof file_data_name !== 'string' || file_data_name.trim() === '') { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: '"file_data_name" must be a non-empty string.' }); } // Validate container (if provided) if (container !== null) { let containerEl = null; if (typeof container === 'string') { containerEl = document.getElementById(container); } else if (container instanceof HTMLElement) { containerEl = container; } if (!containerEl) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: 'Invalid "container" element.' }); } else { this.#containerEl = containerEl; } } // Validate http_method const validHttpMethods = ['POST', 'PUT']; if (!validHttpMethods.includes(http_method.toUpperCase())) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: `"http_method" must be one of: ${validHttpMethods.join(', ')}.` }); } this.#init(); } /** * Parses human-readable size string to bytes. * @param {string|number} size * @returns {number} Size in bytes */ #parseSize(size) { if (typeof size === 'number') return size; if (typeof size === 'string') { const match = size.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)(b|kb|mb|gb|tb)?$/); if (!match) return NaN; const [, value, unit = 'b'] = match; const multipliers = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3, tb: 1024 ** 4, }; return parseFloat(value) * multipliers[unit]; } return NaN; } /** * Starts the upload process. */ start() { if (this.state !== Uploader.STATES.STARTED) { this.state = Uploader.STATES.STARTED; this.trigger('StateChanged', this); this.#uploadNext(); } } /** * Stops the upload process. */ stop() { if (this.state !== Uploader.STATES.STOPPED) { this.state = Uploader.STATES.STOPPED; this.trigger('StateChanged', this); this.trigger('CancelUpload', this); } } /** * Disables or enables the browse (file input) button. * @param {boolean} disable - Whether to disable (true) or enable (false) */ disableBrowse(disable = true) { this.disabled = disable; if (this.#fileInput) { this.#fileInput.disabled = disable; } this.trigger('DisableBrowse', this, disable); } /** * Public method to destroy the uploader instance. */ destroy() { this.trigger('Destroy', this); this.unbindAll(); this.#destroyed = true; } /** * Checks if the instance has been destroyed. * @returns {boolean} */ isDestroyed() { return this.#destroyed; } /** * Returns a file from the queue by ID. * @param {string} id - The file ID. * @returns {File|undefined} */ getFile(id) { return this.files.find(file => file.id === id); } /** * Adds a file (or multiple files) to the queue. * @param {File|Blob|File[]|Blob[]|HTMLInputElement} input - Single or multiple files or an input element. * @param {string} [fileName] - Optional override name for a single file. */ addFile(input, fileName) { const inputType = Object.prototype.toString.call(input); const handleSingleFile = (nativeFile) => { const file = new File(nativeFile); if (fileName) file.name = fileName; if (this.options.filters.prevent_duplicates) { const duplicate = this.files.some(f => f.name === file.name && f.size === file.size); if (duplicate) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.FILE_DUPLICATE, message: 'Duplicate file detected.', file: file, }); return; } } if (this.#extensions.length) { const fileExt = file.name?.split('.').pop().toLowerCase(); if (!fileExt || !this.#extensions.includes(fileExt)) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.FILE_EXTENSION, message: 'Invalid file extension.', file: file, }); return; } } if (this.#maxFileSize && file.size > this.#maxFileSize) { this.trigger('Error', this, { code: Uploader.ERROR_CODES.FILE_SIZE, message: 'File size exceeds limit.', file: file, }); return; } this.files.push(file); this.trigger('FileFiltered', this, file); this.trigger('FilesAdded', this, [file]); }; if (inputType === '[object File]' || inputType === '[object Blob]') { handleSingleFile(input); } else if (Array.isArray(input)) { input.forEach(file => handleSingleFile(file)); } else if (input instanceof HTMLInputElement && input.files?.length) { [...input.files].forEach(file => handleSingleFile(file)); } else { this.trigger('Error', this, { code: Uploader.ERROR_CODES.CONFIG, message: 'Unsupported input type for addFile().' }); } } /** * Removes a file from the queue by ID or object reference. * Triggers FilesRemoved. * @param {File|string} file - File object or ID * @returns {File|undefined} */ removeFile(file) { const id = typeof file === 'string' ? file : file.id; const index = this.files.findIndex(f => f.id === id); if (index !== -1) { const [removed] = this.files.splice(index, 1); removed.destroy(); this.trigger('FilesRemoved', this, [removed]); return removed; } } /** * Bind core uploader event listeners. */ bindEventListeners() { this.bind('FilesAdded', () => { this.trigger('QueueChanged', this); }); this.bind('FilesRemoved', () => { this.trigger('QueueChanged', this); }); this.bind('CancelUpload', this.#onCancelUpload.bind(this)); this.bind('BeforeUpload', this.#onBeforeUpload.bind(this)); this.bind('UploadFile', this.#onUploadFile.bind(this)); this.bind('UploadProgress', this.#onUploadProgress.bind(this)); this.bind('StateChanged', this.#onStateChanged.bind(this)); this.bind('QueueChanged', this.#calc.bind(this)); this.bind('Error', this.#onError.bind(this)); this.bind('FileUploaded', this.#onFileUploaded.bind(this)); this.bind('Destroy', this.#onDestroy.bind(this)); } /** * Aborts any ongoing XHR upload request. * Triggered when user stops the upload or an error requires cancellation. */ #onCancelUpload() { // If an active upload request exists, abort it if (this.xhr) { this.xhr.abort(); this.xhr = null; // Clear the reference } } /** * Prepares the file for upload by assigning a unique target name if required. * Triggered by the 'BeforeUpload' event. */ #onBeforeUpload(uploader, file) { if (this.options.unique_names) { const match = file.name.match(/\.([^.]+)$/); const ext = match ? match[1] : 'part'; file.target_name = `${file.id}.${ext}`; } } /** * Handles the actual upload of a file, including chunked upload logic and retries. * Triggered by the 'UploadFile' event. */ #onUploadFile(uploader, file) { const url = this.options.url; const chunkSize = this.#chunkSize; let retries = this.options.max_retries; let offset = 0; let blob = file.getSource(); let xhr; if (file.loaded) { offset = file.loaded = chunkSize ? chunkSize * Math.floor(file.loaded / chunkSize) : 0; } const handleError = () => { if (retries-- > 0) { setTimeout(uploadNextChunk, 1000); } else { file.loaded = offset; this.trigger('Error', this, { code: Uploader.ERROR_CODES.HTTP, message: 'HTTP Error.', file: file, response: xhr?.responseText, status: xhr?.status, responseHeaders: xhr?.getAllResponseHeaders?.() }); } }; const uploadNextChunk = () => { if (file.status !== Uploader.STATES.UPLOADING || this.state === Uploader.STATES.STOPPED) return; let chunkBlob; let curChunkSize; const args = {}; if (this.options.send_file_name) { args.name = file.target_name || file.name; } if (chunkSize && blob.size > chunkSize) { curChunkSize = Math.min(chunkSize, blob.size - offset); chunkBlob = blob.slice(offset, offset + curChunkSize); } else { curChunkSize = blob.size; chunkBlob = blob; } if (chunkSize) { if (this.options.send_chunk_number) { args.chunk = Math.ceil(offset / chunkSize); args.chunks = Math.ceil(blob.size / chunkSize); } else { args.offset = offset; args.total = blob.size; } } if (this.trigger('BeforeChunkUpload', this, file, args, chunkBlob, offset) !== false) { uploadChunk(args, chunkBlob, curChunkSize); } }; const uploadChunk = (args, chunkBlob, curChunkSize) => { xhr = new XMLHttpRequest(); this.xhr = xhr; if (xhr.upload) { xhr.upload.onprogress = (e) => { file.loaded = Math.min(file.size, offset + e.loaded); this.trigger('UploadProgress', this, file); }; } xhr.onload = () => { if (xhr.status < 200 || xhr.status >= 400) { handleError(); return; } retries = this.options.max_retries; if (curChunkSize < blob.size) { offset += curChunkSize; file.loaded = Math.min(offset, blob.size); this.trigger('ChunkUploaded', this, file, { offset: file.loaded, total: blob.size, response: xhr.responseText, status: xhr.status, responseHeaders: xhr.getAllResponseHeaders() }); if (navigator.userAgent.includes('Android')) { this.trigger('UploadProgress', this, file); } } else { file.loaded = file.size; } chunkBlob = null; if (!offset || offset >= blob.size) { if (file.size !== file.origSize) { blob = null; } this.trigger('UploadProgress', this, file); file.status = Uploader.STATES.DONE; file.completeTimestamp = Date.now(); this.trigger('FileUploaded', this, file, { response: xhr.responseText, status: xhr.status, responseHeaders: xhr.getAllResponseHeaders() }); } else { setTimeout(uploadNextChunk, 1); } }; xhr.onerror = handleError; xhr.onloadend = function () { this.destroy?.(); }; xhr.open(this.options.http_method, url, true); Object.entries(this.options.headers).forEach(([name, value]) => { xhr.setRequestHeader(name, value); }); const formData = new FormData(); for (const [key, value] of Object.entries(args)) { formData.append(key, value); } formData.append(this.options.file_data_name, chunkBlob); xhr.send(formData); }; uploadNextChunk(); } #calcFile(file) { // Calculate upload progress percentage for the file file.percent = file.size > 0 ? Math.ceil((file.loaded / file.size) * 100) : 100; // Recalculate total queue progress this.#calc(); } /** * Updates progress state and recalculates file and total progress. * Triggered by the 'UploadProgress' event. */ #onUploadProgress(uploader, file) { this.#calcFile(file); } /** * Finds the next queued file and starts its upload. * If all files are DONE or FAILED, stops the uploader and triggers completion. * @private */ #uploadNext() { if (this.state !== Uploader.STATES.STARTED) return; let fileToUpload = null; let processedCount = 0; for (const file of this.files) { if (!fileToUpload && file.status === Uploader.STATES.QUEUED) { // Allow BeforeUpload to cancel upload const proceed = this.trigger('BeforeUpload', this, file); if (proceed === false) { file.status = Uploader.STATES.FAILED; continue; } file.status = Uploader.STATES.UPLOADING; file.startTimestamp = Date.now(); fileToUpload = file; this.trigger('UploadFile', this, file); } else { processedCount++; } } // If all files are either DONE or FAILED if (processedCount === this.files.length && !fileToUpload) { this.state = Uploader.STATES.STOPPED; this.trigger('StateChanged', this); this.trigger('UploadComplete', this, this.files); } } /** * Handles changes in uploader state (STARTED or STOPPED). * When started, it sets a timestamp to track upload speed. * When stopped, it resets the status of files that were uploading. * This helps to resume incomplete uploads later. */ #onStateChanged() { if (this.state === Uploader.STATES.STARTED) { // Set the start time to now, used for calculating bytes/sec this.startTime = Date.now(); } else if (this.state === Uploader.STATES.STOPPED) { // Loop through all files in the queue for (let i = this.files.length - 1; i >= 0; i--) { const file = this.files[i]; // If the file was uploading when stopped, mark it as queued if (file.status === Uploader.STATES.UPLOADING) { file.status = Uploader.STATES.QUEUED; // Recalculate progress this.#calc(); } } } } /** * Logs errors, marks files as failed, and triggers recovery or cancel logic. * Triggered by the 'Error' event. */ #onError(uploader, err) { const log = { code: err.code, message: err.message, ...(err.file && { file: err.file.name }), ...(err.status && { status: err.status }), ...(err.response && { response: err.response }), ...(err.responseHeaders && { headers: err.responseHeaders }), }; console.error('[Uploader Error]', log); if (err.code === Uploader.ERROR_CODES.INIT || err.code === Uploader.ERROR_CODES.CONFIG) { // For config/init issues, destroy uploader instance this.destroy(); return; } if (err.code === Uploader.ERROR_CODES.HTTP && err.file) { // Mark file as failed err.file.status = Uploader.STATES.FAILED; err.file.completeTimestamp = Date.now(); // Recalculate progress for the failed file this.#calcFile(err.file); // Cancel current upload (abort xhr if any) if (this.state === Uploader.STATES.STARTED) { this.trigger('CancelUpload', this); // Resume the next upload in the queue after a short delay setTimeout(() => { this.#uploadNext(); }, 1); } } } /** * Marks file as uploaded, recalculates progress, and initiates next upload. * Triggered by the 'FileUploaded' event. */ #onFileUploaded() { this.#calc(); // Recalculate total progress // Delay next file upload to allow custom listeners to react setTimeout(() => { this.#uploadNext(); // Start uploading the next file in queue }, 1); } /** * Removes the hidden file input element if it exists. * - Detaches the <input type="file"> element from the DOM. * - Clears the internal reference to allow clean reinitialization. * - Prevents memory leaks and dangling listeners. * @private */ #removeFileInput() { if (this.#fileInput) { this.#fileInput.remove(); this.#fileInput = null; } } /** * Internal cleanup handler for 'Destroy' event. */ #onDestroy() { this.stop(); // Clean file queue this.files.forEach(file => file.destroy?.()); this.files = []; // Remove file input if present this.#removeFileInput(); // Reset progress this.total.reset(); // Nullify options reference this.options = null; } /** * Calculates the overall upload progress for all files in the queue. * Updates this.total with cumulative metrics. */ #calc() { let loadedDuringSession = 0; this.total.reset(); // Reset previous stats for (const file of this.files) { if (typeof file.size !== 'undefined') { // Accumulate total expected size this.total.size += file.origSize ?? file.size; // Estimate proportionally if resized/compressed const origSize = file.origSize ?? file.size; const loaded = (file.loaded * origSize) / file.size; if (!file.completeTimestamp || file.completeTimestamp > this.startTime) { loadedDuringSession += loaded; } this.total.loaded += loaded; } else { this.total.size = undefined; } // Tally file statuses if (file.status === Uploader.STATES.DONE) { this.total.uploaded++; } else if (file.status === Uploader.STATES.FAILED) { this.total.failed++; } else { this.total.queued++; } } // Calculate percent and bytes/sec if (typeof this.total.size === 'undefined') { const count = this.files.length; this.total.percent = count > 0 ? Math.ceil((this.total.uploaded / count) * 100) : 0; } else { const elapsedSeconds = (Date.now() - this.startTime || 1) / 1000; this.total.bytesPerSec = Math.ceil(loadedDuringSession / elapsedSeconds); this.total.percent = this.total.size > 0 ? Math.ceil((this.total.loaded / this.total.size) * 100) : 0; } } }