UNPKG

@fsdk/upchunk

Version:

Dead simple chunked file uploads using Fetch

367 lines (319 loc) 10.5 kB
import { EventTarget, Event } from 'event-target-shim'; import xhr, { XhrUrlConfig, XhrHeaders, XhrResponse } from 'xhr'; const SUCCESSFUL_CHUNK_UPLOAD_CODES = [200, 201, 202, 204, 308]; const TEMPORARY_ERROR_CODES = [408, 502, 503, 504]; // These error codes imply a chunk may be retried type EventName = | 'attempt' | 'attemptFailure' | 'chunkSuccess' | 'error' | 'offline' | 'online' | 'progress' | 'success'; // NOTE: This and the EventTarget definition below could be more precise // by e.g. typing the detail of the CustomEvent per EventName. type UpchunkEvent = CustomEvent & Event<EventName>; type AllowedMethods = | 'PUT' | 'POST' | 'PATCH'; export interface UpChunkOptions { endpoint: string | ((file?: File) => Promise<string>); file: File; method?: AllowedMethods; headers?: XhrHeaders; maxFileSize?: number; chunkSize?: number; retries?: number; delayBeforeRetry?: number; retryCodes?: number[]; } export class UpChunk { public endpoint: string | ((file?: File) => Promise<string>); public file: File; public headers: XhrHeaders; public method: AllowedMethods; public chunkSize: number; public retries: number; public delayBeforeRetry: number; public retryCodes: number[]; private chunk: Blob; private chunkCount: number; private chunkByteSize: number; private maxFileBytes: number; private endpointValue: string; private totalChunks: number; private attemptCount: number; private offline: boolean; private paused: boolean; private success: boolean; private currentXhr?: XMLHttpRequest; private reader: FileReader; private eventTarget: EventTarget<Record<EventName,UpchunkEvent>>; constructor(options: UpChunkOptions) { this.endpoint = options.endpoint; this.file = options.file; this.headers = options.headers || ({} as XhrHeaders); this.method = options.method || 'PUT'; this.chunkSize = options.chunkSize || 30720; this.retries = options.retries || 5; this.delayBeforeRetry = options.delayBeforeRetry || 1; this.retryCodes = options.retryCodes || TEMPORARY_ERROR_CODES; this.maxFileBytes = (options.maxFileSize || 0) * 1024; this.chunkCount = 0; this.chunkByteSize = this.chunkSize * 1024; this.totalChunks = Math.ceil(this.file.size / this.chunkByteSize); this.attemptCount = 0; this.offline = false; this.paused = false; this.success = false; this.reader = new FileReader(); this.eventTarget = new EventTarget(); this.validateOptions(); this.getEndpoint().then(() => this.sendChunks()); // restart sync when back online // trigger events when offline/back online if (typeof(window) !== 'undefined') { window.addEventListener('online', () => { if (!this.offline) { return; } this.offline = false; this.dispatch('online'); this.sendChunks(); }); window.addEventListener('offline', () => { this.offline = true; this.dispatch('offline'); }); } } /** * Subscribe to an event */ public on(eventName: EventName, fn: (event: CustomEvent) => void) { this.eventTarget.addEventListener(eventName, fn as EventListener); } public abort() { this.pause(); this.currentXhr?.abort(); } public pause() { this.paused = true; } public resume() { if (this.paused) { this.paused = false; this.sendChunks(); } } /** * Dispatch an event */ private dispatch(eventName: EventName, detail?: any) { const event: UpchunkEvent = new CustomEvent(eventName, { detail }) as UpchunkEvent; this.eventTarget.dispatchEvent(event); } /** * Validate options and throw errors if expectations are violated. */ private validateOptions() { if ( !this.endpoint || (typeof this.endpoint !== 'function' && typeof this.endpoint !== 'string') ) { throw new TypeError( 'endpoint must be defined as a string or a function that returns a promise' ); } if (!(this.file instanceof File)) { throw new TypeError('file must be a File object'); } if (this.headers && typeof this.headers !== 'object') { throw new TypeError('headers must be null or an object'); } if ( this.chunkSize && (typeof this.chunkSize !== 'number' || this.chunkSize <= 0 || this.chunkSize % 256 !== 0) ) { throw new TypeError( 'chunkSize must be a positive number in multiples of 256' ); } if (this.maxFileBytes > 0 && this.maxFileBytes < this.file.size) { throw new Error( `file size exceeds maximum (${this.file.size} > ${this.maxFileBytes})` ); } if ( this.retries && (typeof this.retries !== 'number' || this.retries <= 0) ) { throw new TypeError('retries must be a positive number'); } if ( this.delayBeforeRetry && (typeof this.delayBeforeRetry !== 'number' || this.delayBeforeRetry < 0) ) { throw new TypeError('delayBeforeRetry must be a positive number'); } } /** * Endpoint can either be a URL or a function that returns a promise that resolves to a string. */ private getEndpoint() { if (typeof this.endpoint === 'string') { this.endpointValue = this.endpoint; return Promise.resolve(this.endpoint); } return this.endpoint(this.file).then((value) => { this.endpointValue = value; return this.endpointValue; }); } /** * Get portion of the file of x bytes corresponding to chunkSize */ private getChunk() { return new Promise<void> ((resolve) => { // Since we start with 0-chunkSize for the range, we need to subtract 1. const length = this.totalChunks === 1 ? this.file.size : this.chunkByteSize; const start = length * this.chunkCount; this.reader.onload = () => { if (this.reader.result !== null) { this.chunk = new Blob([this.reader.result], { type: 'application/octet-stream', }); } resolve(); }; this.reader.readAsArrayBuffer(this.file.slice(start, start + length)); }); } private xhrPromise(options: XhrUrlConfig): Promise<XhrResponse> { const beforeSend = (xhrObject: XMLHttpRequest) => { xhrObject.upload.onprogress = (event: ProgressEvent) => { const percentagePerChunk = 100 / this.totalChunks; const sizePerChunk = percentagePerChunk * this.file.size; const successfulPercentage = percentagePerChunk * this.chunkCount; const currentChunkProgress = event.loaded / (event.total ?? sizePerChunk); const chunkPercentage = currentChunkProgress * percentagePerChunk; this.dispatch('progress', Math.min(successfulPercentage + chunkPercentage, 100)); }; }; return new Promise((resolve, reject) => { this.currentXhr = xhr({ ...options, beforeSend }, (err, resp) => { this.currentXhr = undefined; if (err) { return reject(err); } return resolve(resp); }); }); } /** * Send chunk of the file with appropriate headers */ protected async sendChunk() { const rangeStart = this.chunkCount * this.chunkByteSize; const rangeEnd = rangeStart + this.chunk.size - 1; const headers = { ...this.headers, 'Content-Type': this.file.type, 'Content-Range': `bytes ${rangeStart}-${rangeEnd}/${this.file.size}`, }; this.dispatch('attempt', { chunkNumber: this.chunkCount, chunkSize: this.chunk.size, }); return this.xhrPromise({ headers, url: this.endpointValue, method: this.method, body: this.chunk, }); } /** * Called on net failure. If retry counter !== 0, retry after delayBeforeRetry */ private manageRetries() { if (this.attemptCount < this.retries) { setTimeout(() => this.sendChunks(), this.delayBeforeRetry * 1000); this.dispatch('attemptFailure', { message: `An error occured uploading chunk ${this.chunkCount}. ${ this.retries - this.attemptCount } retries left.`, chunkNumber: this.chunkCount, attemptsLeft: this.retries - this.attemptCount, }); return; } this.dispatch('error', { message: `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload`, chunk: this.chunkCount, attempts: this.attemptCount, }); } /** * Manage the whole upload by calling getChunk & sendChunk * handle errors & retries and dispatch events */ private sendChunks() { if (this.paused || this.offline || this.success) { return; } this.getChunk() .then(() => { this.attemptCount = this.attemptCount + 1; return this.sendChunk() }) .then((res) => { if (SUCCESSFUL_CHUNK_UPLOAD_CODES.includes(res.statusCode)) { this.dispatch('chunkSuccess', { chunk: this.chunkCount, attempts: this.attemptCount, response: res, }); this.attemptCount = 0; this.chunkCount = this.chunkCount + 1; if (this.chunkCount < this.totalChunks) { this.sendChunks(); } else { this.success = true; this.dispatch('success'); } const chunkFraction = this.chunkCount / this.totalChunks; const uploadedBytes = chunkFraction * this.file.size; const percentProgress = (100 * uploadedBytes) / this.file.size; this.dispatch('progress', percentProgress); } else if (this.retryCodes.includes(res.statusCode)) { if (this.paused || this.offline) { return; } this.manageRetries(); } else { if (this.paused || this.offline) { return; } this.dispatch('error', { message: `Server responded with ${res.statusCode}. Stopping upload.`, chunkNumber: this.chunkCount, attempts: this.attemptCount, }); } }) .catch((err) => { if (this.paused || this.offline) { return; } // this type of error can happen after network disconnection on CORS setup this.manageRetries(); }); } } export const createUpload = (options: UpChunkOptions) => new UpChunk(options);