tus-js-client-olalonde
Version:
A pure JavaScript client for the tus resumable upload protocol
457 lines (371 loc) • 13.5 kB
JavaScript
/* global window */
import fingerprint from "./fingerprint";
import DetailedError from "./error";
import extend from "extend";
// We import the files used inside the Node environment which are rewritten
// for browsers using the rules defined in the package.json
import {newRequest, resolveUrl} from "./node/request";
import {getSource} from "./node/source";
import * as Base64 from "./node/base64";
import * as Storage from "./node/storage";
const defaultOptions = {
endpoint: "",
fingerprint,
resume: true,
onProgress: null,
onChunkComplete: null,
onSuccess: null,
onError: null,
headers: {},
chunkSize: Infinity,
withCredentials: false,
uploadUrl: null,
uploadSize: null,
overridePatchMethod: false,
retryDelays: null
};
class Upload {
constructor(file, options) {
this.options = extend(true, {}, defaultOptions, options);
// The underlying File/Blob object
this.file = file;
// The URL against which the file will be uploaded
this.url = null;
// The underlying XHR object for the current PATCH request
this._xhr = null;
// The fingerpinrt for the current file (set after start())
this._fingerprint = null;
// The offset used in the current PATCH request
this._offset = null;
// True if the current PATCH request has been aborted
this._aborted = false;
// The file's size in bytes
this._size = null;
// The Source object which will wrap around the given file and provides us
// with a unified interface for getting its size and slice chunks from its
// content allowing us to easily handle Files, Blobs, Buffers and Streams.
this._source = null;
// The current count of attempts which have been made. Null indicates none.
this._retryAttempt = 0;
// The timeout's ID which is used to delay the next retry
this._retryTimeout = null;
// The offset of the remote upload before the latest attempt was started.
this._offsetBeforeRetry = 0;
}
start() {
let file = this.file;
if (!file) {
this._emitError(new Error("tus: no file or stream to upload provided"));
return;
}
if (!this.options.endpoint) {
this._emitError(new Error("tus: no endpoint provided"));
return;
}
let source = this._source = getSource(file, this.options.chunkSize);
// Firstly, check if the caller has supplied a manual upload size or else
// we will use the calculated size by the source object.
if (this.options.uploadSize != null) {
let size = +this.options.uploadSize;
if (isNaN(size)) {
throw new Error("tus: cannot convert `uploadSize` option into a number");
}
this._size = size;
} else {
let size = source.size;
// The size property will be null if we cannot calculate the file's size,
// for example if you handle a stream.
if (size == null) {
throw new Error("tus: cannot automatically derive upload's size from input and must be specified manually using the `uploadSize` option");
}
this._size = size;
}
let retryDelays = this.options.retryDelays;
if (retryDelays != null) {
if (Object.prototype.toString.call(retryDelays) !== "[object Array]") {
throw new Error("tus: the `retryDelays` option must either be an array or null");
} else {
let errorCallback = this.options.onError;
this.options.onError = (err) => {
// Restore the original error callback which may have been set.
this.options.onError = errorCallback;
// We will reset the attempt counter if
// - we were already able to connect to the server (offset != null) and
// - we were able to upload a small chunk of data to the server
let shouldResetDelays = this._offset != null && (this._offset > this._offsetBeforeRetry);
if (shouldResetDelays) {
this._retryAttempt = 0;
}
let isOnline = true;
if (typeof window !== "undefined" &&
"navigator" in window &&
window.navigator.onLine === false) {
isOnline = false;
}
// We only attempt a retry if
// - we didn't exceed the maxium number of retries, yet, and
// - this error was caused by a request or it's response and
// - the browser does not indicate that we are offline
let shouldRetry = this._retryAttempt < retryDelays.length &&
err.originalRequest != null &&
isOnline;
if (!shouldRetry) {
this._emitError(err);
return;
}
let delay = retryDelays[this._retryAttempt++];
this._offsetBeforeRetry = this._offset;
this.options.uploadUrl = this.url;
this._retryTimeout = setTimeout(() => {
this.start();
}, delay);
};
}
}
// A URL has manually been specified, so we try to resume
if (this.options.uploadUrl != null) {
this.url = this.options.uploadUrl;
this._resumeUpload();
return;
}
// Try to find the endpoint for the file in the storage
if (this.options.resume) {
this._fingerprint = this.options.fingerprint(file);
let resumedUrl = Storage.getItem(this._fingerprint);
if (resumedUrl != null) {
this.url = resumedUrl;
this._resumeUpload();
return;
}
}
// An upload has not started for the file yet, so we start a new one
this._createUpload();
}
abort() {
if (this._xhr !== null) {
this._xhr.abort();
this._source.close();
this._aborted = true;
}
if (this._retryTimeout != null) {
clearTimeout(this._retryTimeout);
this._retryTimeout = null;
}
}
_emitXhrError(xhr, err, causingErr) {
this._emitError(new DetailedError(err, causingErr, xhr));
}
_emitError(err) {
if (typeof this.options.onError === "function") {
this.options.onError(err);
} else {
throw err;
}
}
_emitSuccess() {
if (typeof this.options.onSuccess === "function") {
this.options.onSuccess();
}
}
/**
* Publishes notification when data has been sent to the server. This
* data may not have been accepted by the server yet.
* @param {number} bytesSent Number of bytes sent to the server.
* @param {number} bytesTotal Total number of bytes to be sent to the server.
*/
_emitProgress(bytesSent, bytesTotal) {
if (typeof this.options.onProgress === "function") {
this.options.onProgress(bytesSent, bytesTotal);
}
}
/**
* Publishes notification when a chunk of data has been sent to the server
* and accepted by the server.
* @param {number} chunkSize Size of the chunk that was accepted by the
* server.
* @param {number} bytesAccepted Total number of bytes that have been
* accepted by the server.
* @param {number} bytesTotal Total number of bytes to be sent to the server.
*/
_emitChunkComplete(chunkSize, bytesAccepted, bytesTotal) {
if (typeof this.options.onChunkComplete === "function") {
this.options.onChunkComplete(chunkSize, bytesAccepted, bytesTotal);
}
}
/**
* Set the headers used in the request and the withCredentials property
* as defined in the options
*
* @param {XMLHttpRequest} xhr
*/
_setupXHR(xhr) {
xhr.setRequestHeader("Tus-Resumable", "1.0.0");
let headers = this.options.headers;
for (let name in headers) {
xhr.setRequestHeader(name, headers[name]);
}
xhr.withCredentials = this.options.withCredentials;
}
/**
* Create a new upload using the creation extension by sending a POST
* request to the endpoint. After successful creation the file will be
* uploaded
*
* @api private
*/
_createUpload() {
let xhr = newRequest();
xhr.open("POST", this.options.endpoint, true);
xhr.onload = () => {
if (!(xhr.status >= 200 && xhr.status < 300)) {
this._emitXhrError(xhr, new Error("tus: unexpected response while creating upload"));
return;
}
this.url = resolveUrl(this.options.endpoint, xhr.getResponseHeader("Location"));
if (this.options.resume) {
Storage.setItem(this._fingerprint, this.url);
}
this._offset = 0;
this._startUpload();
};
xhr.onerror = (err) => {
this._emitXhrError(xhr, new Error("tus: failed to create upload"), err);
};
this._setupXHR(xhr);
xhr.setRequestHeader("Upload-Length", this._size);
// Add metadata if values have been added
var metadata = encodeMetadata(this.options.metadata);
if (metadata !== "") {
xhr.setRequestHeader("Upload-Metadata", metadata);
}
xhr.send(null);
}
/*
* Try to resume an existing upload. First a HEAD request will be sent
* to retrieve the offset. If the request fails a new upload will be
* created. In the case of a successful response the file will be uploaded.
*
* @api private
*/
_resumeUpload() {
let xhr = newRequest();
xhr.open("HEAD", this.url, true);
xhr.onload = () => {
if (!(xhr.status >= 200 && xhr.status < 300)) {
if (this.options.resume) {
// Remove stored fingerprint and corresponding endpoint,
// since the file can not be found
Storage.removeItem(this._fingerprint);
}
// Try to create a new upload
this.url = null;
this._createUpload();
return;
}
let offset = parseInt(xhr.getResponseHeader("Upload-Offset"), 10);
if (isNaN(offset)) {
this._emitXhrError(xhr, new Error("tus: invalid or missing offset value"));
return;
}
let length = parseInt(xhr.getResponseHeader("Upload-Length"), 10);
if (isNaN(length)) {
this._emitXhrError(xhr, new Error("tus: invalid or missing length value"));
return;
}
// Upload has already been completed and we do not need to send additional
// data to the server
if (offset === length) {
this._emitProgress(length, length);
this._emitSuccess();
return;
}
this._offset = offset;
this._startUpload();
};
xhr.onerror = (err) => {
this._emitXhrError(xhr, new Error("tus: failed to resume upload"), err);
};
this._setupXHR(xhr);
xhr.send(null);
}
/**
* Start uploading the file using PATCH requests. The file will be divided
* into chunks as specified in the chunkSize option. During the upload
* the onProgress event handler may be invoked multiple times.
*
* @api private
*/
_startUpload() {
let xhr = this._xhr = newRequest();
// Some browser and servers may not support the PATCH method. For those
// cases, you can tell tus-js-client to use a POST request with the
// X-HTTP-Method-Override header for simulating a PATCH request.
if (this.options.overridePatchMethod) {
xhr.open("POST", this.url, true);
xhr.setRequestHeader("X-HTTP-Method-Override", "PATCH");
} else {
xhr.open("PATCH", this.url, true);
}
xhr.onload = () => {
if (!(xhr.status >= 200 && xhr.status < 300)) {
this._emitXhrError(xhr, new Error("tus: unexpected response while uploading chunk"));
return;
}
let offset = parseInt(xhr.getResponseHeader("Upload-Offset"), 10);
if (isNaN(offset)) {
this._emitXhrError(xhr, new Error("tus: invalid or missing offset value"));
return;
}
this._emitProgress(offset, this._size);
this._emitChunkComplete(offset - this._offset, offset, this._size);
this._offset = offset;
if (offset == this._size) {
// Yay, finally done :)
this._emitSuccess();
this._source.close();
return;
}
this._startUpload();
};
xhr.onerror = (err) => {
// Don't emit an error if the upload was aborted manually
if (this._aborted) {
return;
}
this._emitXhrError(xhr, new Error("tus: failed to upload chunk at offset " + this._offset), err);
};
// Test support for progress events before attaching an event listener
if ("upload" in xhr) {
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) {
return;
}
this._emitProgress(start + e.loaded, this._size);
};
}
this._setupXHR(xhr);
xhr.setRequestHeader("Upload-Offset", this._offset);
xhr.setRequestHeader("Content-Type", "application/offset+octet-stream");
let start = this._offset;
let end = this._offset + this.options.chunkSize;
// The specified chunkSize may be Infinity or the calcluated end position
// may exceed the file's size. In both cases, we limit the end position to
// the input's total size for simpler calculations and correctness.
if (end === Infinity || end > this._size) {
end = this._size;
}
xhr.send(this._source.slice(start, end));
}
}
function encodeMetadata(metadata) {
if (!Base64.isSupported) {
return "";
}
var encoded = [];
for (var key in metadata) {
encoded.push(key + " " + Base64.encode(metadata[key]));
}
return encoded.join(",");
}
Upload.defaultOptions = defaultOptions;
export default Upload;