@happylinks/upchunk
Version:
Dead simple chunked file uploads using Fetch
298 lines (260 loc) • 8.1 kB
text/typescript
import { EventTarget } from 'event-target-shim';
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'
| 'error'
| 'offline'
| 'online'
| 'progress'
| 'success';
interface IOptions {
endpoint: string | ((file?: File) => Promise<string>);
file: File;
headers?: Headers;
chunkSize?: number;
attempts?: number;
delayBeforeAttempt?: number;
}
export class UpChunk {
public endpoint: string | ((file?: File) => Promise<string>);
public file: File;
public headers: Headers;
public chunkSize: number;
public attempts: number;
public delayBeforeAttempt: number;
private chunk: Blob;
private chunkCount: number;
private chunkByteSize: number;
private endpointValue: string;
private totalChunks: number;
private attemptCount: number;
private offline: boolean;
private paused: boolean;
private reader: FileReader;
private eventTarget: EventTarget;
constructor(options: IOptions) {
this.endpoint = options.endpoint;
this.file = options.file;
this.headers = options.headers || ({} as Headers);
this.chunkSize = options.chunkSize || 5120;
this.attempts = options.attempts || 5;
this.delayBeforeAttempt = options.delayBeforeAttempt || 1;
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.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);
}
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 = new CustomEvent(eventName, { detail });
this.eventTarget.dispatchEvent(event);
}
/**
* Validate options and throw error if not of the right type
*/
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.attempts &&
(typeof this.attempts !== 'number' || this.attempts <= 0)
) {
throw new TypeError('retries must be a positive number');
}
if (
this.delayBeforeAttempt &&
(typeof this.delayBeforeAttempt !== 'number' ||
this.delayBeforeAttempt < 0)
) {
throw new TypeError('delayBeforeAttempt 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(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));
});
}
/**
* Send chunk of the file with appropriate headers and add post parameters if it's last chunk
*/
private sendChunk() {
const rangeStart = this.chunkCount * this.chunkByteSize;
const rangeEnd = rangeStart + this.chunk.size - 1;
const headers = {
...this.headers,
'Content-Type': this.file.type,
'Content-Length': this.chunk.size,
'Content-Range': `bytes ${rangeStart}-${rangeEnd}/${this.file.size}`,
};
this.dispatch('attempt', {
chunkNumber: this.chunkCount,
chunkSize: this.chunk.size,
});
return fetch(this.endpointValue, {
headers,
method: 'PUT',
body: this.chunk,
});
}
/**
* Called on net failure. If retry counter !== 0, retry after delayBeforeAttempt
*/
private manageRetries() {
if (this.attemptCount < this.attempts) {
this.attemptCount = this.attemptCount + 1;
setTimeout(() => this.sendChunks(), this.delayBeforeAttempt * 1000);
this.dispatch('attemptFailure', {
message: `An error occured uploading chunk ${this.chunkCount}. ${this
.attempts - this.attemptCount} retries left.`,
chunkNumber: this.chunkCount,
attemptsLeft: this.attempts - 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) {
return;
}
this.getChunk()
.then(() => this.sendChunk())
.then(res => {
if (SUCCESSFUL_CHUNK_UPLOAD_CODES.includes(res.status)) {
this.chunkCount = this.chunkCount + 1;
if (this.chunkCount < this.totalChunks) {
this.sendChunks();
} else {
this.dispatch('success');
}
const percentProgress = Math.round(
(100 / this.totalChunks) * this.chunkCount
);
this.dispatch('progress', percentProgress);
} else if (TEMPORARY_ERROR_CODES.includes(res.status)) {
if (this.paused || this.offline) {
return;
}
this.manageRetries();
} else {
if (this.paused || this.offline) {
return;
}
this.dispatch('error', {
message: `Server responded with ${res.status}. 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: IOptions) => new UpChunk(options);