@uppy/tus
Version:
Resumable uploads for Uppy using Tus.io
513 lines (505 loc) • 20.7 kB
JavaScript
function _classPrivateFieldLooseBase(e, t) { if (!{}.hasOwnProperty.call(e, t)) throw new TypeError("attempted to use private field on non-instance"); return e; }
var id = 0;
function _classPrivateFieldLooseKey(e) { return "__private_" + id++ + "_" + e; }
import { BasePlugin } from '@uppy/core';
import * as tus from 'tus-js-client';
import EventManager from '@uppy/core/lib/EventManager.js';
import NetworkError from '@uppy/utils/lib/NetworkError';
import isNetworkError from '@uppy/utils/lib/isNetworkError';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore untyped
import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue';
import hasProperty from '@uppy/utils/lib/hasProperty';
import { filterNonFailedFiles, filterFilesToEmitUploadStarted } from '@uppy/utils/lib/fileFilters';
import getAllowedMetaFields from '@uppy/utils/lib/getAllowedMetaFields';
import getFingerprint from './getFingerprint.js';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore We don't want TS to generate types for the package.json
const packageJson = {
"version": "4.2.2"
};
/**
* Extracted from https://github.com/tus/tus-js-client/blob/master/lib/upload.js#L13
* excepted we removed 'fingerprint' key to avoid adding more dependencies
*/
const tusDefaultOptions = {
endpoint: '',
uploadUrl: null,
metadata: {},
uploadSize: null,
onProgress: null,
onChunkComplete: null,
onSuccess: null,
onError: null,
overridePatchMethod: false,
headers: {},
addRequestId: false,
chunkSize: Infinity,
retryDelays: [100, 1000, 3000, 5000],
parallelUploads: 1,
removeFingerprintOnSuccess: false,
uploadLengthDeferred: false,
uploadDataDuringCreation: false
};
const defaultOptions = {
limit: 20,
retryDelays: tusDefaultOptions.retryDelays,
withCredentials: false,
allowedMetaFields: true
};
var _retryDelayIterator = /*#__PURE__*/_classPrivateFieldLooseKey("retryDelayIterator");
var _uploadLocalFile = /*#__PURE__*/_classPrivateFieldLooseKey("uploadLocalFile");
var _getCompanionClientArgs = /*#__PURE__*/_classPrivateFieldLooseKey("getCompanionClientArgs");
var _uploadFiles = /*#__PURE__*/_classPrivateFieldLooseKey("uploadFiles");
var _handleUpload = /*#__PURE__*/_classPrivateFieldLooseKey("handleUpload");
/**
* Tus resumable file uploader
*/
export default class Tus extends BasePlugin {
constructor(uppy, _opts) {
var _this$opts$rateLimite, _this$opts$retryDelay;
super(uppy, {
...defaultOptions,
..._opts
});
Object.defineProperty(this, _uploadFiles, {
value: _uploadFiles2
});
Object.defineProperty(this, _getCompanionClientArgs, {
value: _getCompanionClientArgs2
});
/**
* Create a new Tus upload.
*
* A lot can happen during an upload, so this is quite hard to follow!
* - First, the upload is started. If the file was already paused by the time the upload starts, nothing should happen.
* If the `limit` option is used, the upload must be queued onto the `this.requests` queue.
* When an upload starts, we store the tus.Upload instance, and an EventManager instance that manages the event listeners
* for pausing, cancellation, removal, etc.
* - While the upload is in progress, it may be paused or cancelled.
* Pausing aborts the underlying tus.Upload, and removes the upload from the `this.requests` queue. All other state is
* maintained.
* Cancelling removes the upload from the `this.requests` queue, and completely aborts the upload-- the `tus.Upload`
* instance is aborted and discarded, the EventManager instance is destroyed (removing all listeners).
* Resuming the upload uses the `this.requests` queue as well, to prevent selectively pausing and resuming uploads from
* bypassing the limit.
* - After completing an upload, the tus.Upload and EventManager instances are cleaned up, and the upload is marked as done
* in the `this.requests` queue.
* - When an upload completed with an error, the same happens as on successful completion, but the `upload()` promise is
* rejected.
*
* When working on this function, keep in mind:
* - When an upload is completed or cancelled for any reason, the tus.Upload and EventManager instances need to be cleaned
* up using this.resetUploaderReferences().
* - When an upload is cancelled or paused, for any reason, it needs to be removed from the `this.requests` queue using
* `queuedRequest.abort()`.
* - When an upload is completed for any reason, including errors, it needs to be marked as such using
* `queuedRequest.done()`.
* - When an upload is started or resumed, it needs to go through the `this.requests` queue. The `queuedRequest` variable
* must be updated so the other uses of it are valid.
* - Before replacing the `queuedRequest` variable, the previous `queuedRequest` must be aborted, else it will keep taking
* up a spot in the queue.
*
*/
Object.defineProperty(this, _uploadLocalFile, {
value: _uploadLocalFile2
});
Object.defineProperty(this, _retryDelayIterator, {
writable: true,
value: void 0
});
Object.defineProperty(this, _handleUpload, {
writable: true,
value: async fileIDs => {
if (fileIDs.length === 0) {
this.uppy.log('[Tus] No files to upload');
return;
}
if (this.opts.limit === 0) {
this.uppy.log('[Tus] When uploading multiple files at once, consider setting the `limit` option (to `10` for example), to limit the number of concurrent uploads, which helps prevent memory and network issues: https://uppy.io/docs/tus/#limit-0', 'warning');
}
this.uppy.log('[Tus] Uploading...');
const filesToUpload = this.uppy.getFilesByIds(fileIDs);
await _classPrivateFieldLooseBase(this, _uploadFiles)[_uploadFiles](filesToUpload);
}
});
this.type = 'uploader';
this.id = this.opts.id || 'Tus';
if ((_opts == null ? void 0 : _opts.allowedMetaFields) === undefined && 'metaFields' in this.opts) {
throw new Error('The `metaFields` option has been renamed to `allowedMetaFields`.');
}
if ('autoRetry' in _opts) {
throw new Error('The `autoRetry` option was deprecated and has been removed.');
}
/**
* Simultaneous upload limiting is shared across all uploads with this plugin.
*
* @type {RateLimitedQueue}
*/
this.requests = (_this$opts$rateLimite = this.opts.rateLimitedQueue) != null ? _this$opts$rateLimite : new RateLimitedQueue(this.opts.limit);
_classPrivateFieldLooseBase(this, _retryDelayIterator)[_retryDelayIterator] = (_this$opts$retryDelay = this.opts.retryDelays) == null ? void 0 : _this$opts$retryDelay.values();
this.uploaders = Object.create(null);
this.uploaderEvents = Object.create(null);
}
/**
* Clean up all references for a file's upload: the tus.Upload instance,
* any events related to the file, and the Companion WebSocket connection.
*/
resetUploaderReferences(fileID, opts) {
const uploader = this.uploaders[fileID];
if (uploader) {
uploader.abort();
if (opts != null && opts.abort) {
uploader.abort(true);
}
this.uploaders[fileID] = null;
}
if (this.uploaderEvents[fileID]) {
this.uploaderEvents[fileID].remove();
this.uploaderEvents[fileID] = null;
}
}
/**
* Store the uploadUrl on the file options, so that when Golden Retriever
* restores state, we will continue uploading to the correct URL.
*/
onReceiveUploadUrl(file, uploadURL) {
const currentFile = this.uppy.getFile(file.id);
if (!currentFile) return;
// Only do the update if we didn't have an upload URL yet.
if (!currentFile.tus || currentFile.tus.uploadUrl !== uploadURL) {
this.uppy.log('[Tus] Storing upload url');
this.uppy.setFileState(currentFile.id, {
tus: {
...currentFile.tus,
uploadUrl: uploadURL
}
});
}
}
install() {
this.uppy.setState({
capabilities: {
...this.uppy.getState().capabilities,
resumableUploads: true
}
});
this.uppy.addUploader(_classPrivateFieldLooseBase(this, _handleUpload)[_handleUpload]);
}
uninstall() {
this.uppy.setState({
capabilities: {
...this.uppy.getState().capabilities,
resumableUploads: false
}
});
this.uppy.removeUploader(_classPrivateFieldLooseBase(this, _handleUpload)[_handleUpload]);
}
}
function _uploadLocalFile2(file) {
this.resetUploaderReferences(file.id);
// Create a new tus upload
return new Promise((resolve, reject) => {
let queuedRequest;
let qRequest;
let upload;
const opts = {
...this.opts,
...(file.tus || {})
};
if (typeof opts.headers === 'function') {
opts.headers = opts.headers(file);
}
const {
onShouldRetry,
onBeforeRequest,
...commonOpts
} = opts;
const uploadOptions = {
...tusDefaultOptions,
...commonOpts
};
// We override tus fingerprint to uppy’s `file.id`, since the `file.id`
// now also includes `relativePath` for files added from folders.
// This means you can add 2 identical files, if one is in folder a,
// the other in folder b.
uploadOptions.fingerprint = getFingerprint(file);
uploadOptions.onBeforeRequest = async req => {
const xhr = req.getUnderlyingObject();
xhr.withCredentials = !!opts.withCredentials;
let userProvidedPromise;
if (typeof onBeforeRequest === 'function') {
userProvidedPromise = onBeforeRequest(req, file);
}
if (hasProperty(queuedRequest, 'shouldBeRequeued')) {
if (!queuedRequest.shouldBeRequeued) return Promise.reject();
// TODO: switch to `Promise.withResolvers` on the next major if available.
let done;
// eslint-disable-next-line promise/param-names
const p = new Promise(res => {
done = res;
});
queuedRequest = this.requests.run(() => {
if (file.isPaused) {
queuedRequest.abort();
}
done();
return () => {};
});
// If the request has been requeued because it was rate limited by the
// remote server, we want to wait for `RateLimitedQueue` to dispatch
// the re-try request.
// Therefore we create a promise that the queue will resolve when
// enough time has elapsed to expect not to be rate-limited again.
// This means we can hold the Tus retry here with a `Promise.all`,
// together with the returned value of the user provided
// `onBeforeRequest` option callback (in case it returns a promise).
await Promise.all([p, userProvidedPromise]);
return undefined;
}
return userProvidedPromise;
};
uploadOptions.onError = err => {
var _queuedRequest;
this.uppy.log(err);
const xhr = err.originalRequest != null ? err.originalRequest.getUnderlyingObject() : null;
if (isNetworkError(xhr)) {
// eslint-disable-next-line no-param-reassign
err = new NetworkError(err, xhr);
}
this.resetUploaderReferences(file.id);
(_queuedRequest = queuedRequest) == null || _queuedRequest.abort();
if (typeof opts.onError === 'function') {
opts.onError(err);
}
reject(err);
};
uploadOptions.onProgress = (bytesUploaded, bytesTotal) => {
var _latestFile$progress$;
this.onReceiveUploadUrl(file, upload.url);
if (typeof opts.onProgress === 'function') {
opts.onProgress(bytesUploaded, bytesTotal);
}
const latestFile = this.uppy.getFile(file.id);
this.uppy.emit('upload-progress', latestFile, {
uploadStarted: (_latestFile$progress$ = latestFile.progress.uploadStarted) != null ? _latestFile$progress$ : 0,
bytesUploaded,
bytesTotal
});
};
uploadOptions.onSuccess = payload => {
var _upload$url;
const uploadResp = {
uploadURL: (_upload$url = upload.url) != null ? _upload$url : undefined,
status: 200,
body: {
// We have to put `as XMLHttpRequest` because tus-js-client
// returns `any`, as the type differs in Node.js and the browser.
// In the browser it's always `XMLHttpRequest`.
xhr: payload.lastResponse.getUnderlyingObject()
// Body extends Record<string, unknown> and thus `xhr` is not known
// but we export the `TusBody` type, which people pass as a generic into the Uppy class,
// so on the implementer side it works as expected.
}
};
this.uppy.emit('upload-success', this.uppy.getFile(file.id), uploadResp);
this.resetUploaderReferences(file.id);
queuedRequest.done();
if (upload.url) {
// @ts-expect-error not typed in tus-js-client
const {
name
} = upload.file;
this.uppy.log(`Download ${name} from ${upload.url}`);
}
if (typeof opts.onSuccess === 'function') {
opts.onSuccess(payload);
}
resolve(upload);
};
const defaultOnShouldRetry = err => {
var _err$originalResponse;
const status = err == null || (_err$originalResponse = err.originalResponse) == null ? void 0 : _err$originalResponse.getStatus();
if (status === 429) {
// HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
if (!this.requests.isPaused) {
var _classPrivateFieldLoo;
const next = (_classPrivateFieldLoo = _classPrivateFieldLooseBase(this, _retryDelayIterator)[_retryDelayIterator]) == null ? void 0 : _classPrivateFieldLoo.next();
if (next == null || next.done) {
return false;
}
this.requests.rateLimit(next.value);
}
} else if (status != null && status >= 400 && status < 500 && status !== 409 && status !== 423) {
// HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
// HTTP 409 Conflict (happens if the Upload-Offset header does not match the one on the server)
// HTTP 423 Locked (happens when a paused download is resumed too quickly)
return false;
} else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
// The navigator is offline, let's wait for it to come back online.
if (!this.requests.isPaused) {
this.requests.pause();
window.addEventListener('online', () => {
this.requests.resume();
}, {
once: true
});
}
}
queuedRequest.abort();
queuedRequest = {
shouldBeRequeued: true,
abort() {
this.shouldBeRequeued = false;
},
done() {
throw new Error('Cannot mark a queued request as done: this indicates a bug');
},
fn() {
throw new Error('Cannot run a queued request: this indicates a bug');
}
};
return true;
};
if (onShouldRetry != null) {
uploadOptions.onShouldRetry = (error, retryAttempt) => onShouldRetry(error, retryAttempt, opts, defaultOnShouldRetry);
} else {
uploadOptions.onShouldRetry = defaultOnShouldRetry;
}
const copyProp = (obj, srcProp, destProp) => {
if (hasProperty(obj, srcProp) && !hasProperty(obj, destProp)) {
// eslint-disable-next-line no-param-reassign
obj[destProp] = obj[srcProp];
}
};
// We can't use `allowedMetaFields` to index generic M
// and we also don't care about the type specifically here,
// we just want to pass the meta fields along.
const meta = {};
const allowedMetaFields = getAllowedMetaFields(opts.allowedMetaFields, file.meta);
allowedMetaFields.forEach(item => {
// tus type definition for metadata only accepts `Record<string, string>`
// but in reality (at runtime) it accepts `Record<string, unknown>`
// tus internally converts everything into a string, but let's do it here instead to be explicit.
// because Uppy can have anything inside meta values, (for example relativePath: null is often sent by uppy)
meta[item] = String(file.meta[item]);
});
// tusd uses metadata fields 'filetype' and 'filename'
copyProp(meta, 'type', 'filetype');
copyProp(meta, 'name', 'filename');
uploadOptions.metadata = meta;
upload = new tus.Upload(file.data, uploadOptions);
this.uploaders[file.id] = upload;
const eventManager = new EventManager(this.uppy);
this.uploaderEvents[file.id] = eventManager;
// eslint-disable-next-line prefer-const
qRequest = () => {
if (!file.isPaused) {
upload.start();
}
// Don't do anything here, the caller will take care of cancelling the upload itself
// using resetUploaderReferences(). This is because resetUploaderReferences() has to be
// called when this request is still in the queue, and has not been started yet, too. At
// that point this cancellation function is not going to be called.
// Also, we need to remove the request from the queue _without_ destroying everything
// related to this upload to handle pauses.
return () => {};
};
upload.findPreviousUploads().then(previousUploads => {
const previousUpload = previousUploads[0];
if (previousUpload) {
this.uppy.log(`[Tus] Resuming upload of ${file.id} started at ${previousUpload.creationTime}`);
upload.resumeFromPreviousUpload(previousUpload);
}
queuedRequest = this.requests.run(qRequest);
});
eventManager.onFileRemove(file.id, targetFileID => {
queuedRequest.abort();
this.resetUploaderReferences(file.id, {
abort: !!upload.url
});
resolve(`upload ${targetFileID} was removed`);
});
eventManager.onPause(file.id, isPaused => {
queuedRequest.abort();
if (isPaused) {
// Remove this file from the queue so another file can start in its place.
upload.abort();
} else {
// Resuming an upload should be queued, else you could pause and then
// resume a queued upload to make it skip the queue.
queuedRequest = this.requests.run(qRequest);
}
});
eventManager.onPauseAll(file.id, () => {
queuedRequest.abort();
upload.abort();
});
eventManager.onCancelAll(file.id, () => {
queuedRequest.abort();
this.resetUploaderReferences(file.id, {
abort: !!upload.url
});
resolve(`upload ${file.id} was canceled`);
});
eventManager.onResumeAll(file.id, () => {
queuedRequest.abort();
if (file.error) {
upload.abort();
}
queuedRequest = this.requests.run(qRequest);
});
}).catch(err => {
this.uppy.emit('upload-error', file, err);
throw err;
});
}
function _getCompanionClientArgs2(file) {
var _file$remote;
const opts = {
...this.opts
};
if (file.tus) {
// Install file-specific upload overrides.
Object.assign(opts, file.tus);
}
if (typeof opts.headers === 'function') {
opts.headers = opts.headers(file);
}
return {
...((_file$remote = file.remote) == null ? void 0 : _file$remote.body),
endpoint: opts.endpoint,
uploadUrl: opts.uploadUrl,
protocol: 'tus',
size: file.data.size,
headers: opts.headers,
metadata: file.meta
};
}
async function _uploadFiles2(files) {
const filesFiltered = filterNonFailedFiles(files);
const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered);
this.uppy.emit('upload-start', filesToEmit);
await Promise.allSettled(filesFiltered.map(file => {
if (file.isRemote) {
const getQueue = () => this.requests;
const controller = new AbortController();
const removedHandler = removedFile => {
if (removedFile.id === file.id) controller.abort();
};
this.uppy.on('file-removed', removedHandler);
const uploadPromise = this.uppy.getRequestClientForFile(file).uploadRemoteFile(file, _classPrivateFieldLooseBase(this, _getCompanionClientArgs)[_getCompanionClientArgs](file), {
signal: controller.signal,
getQueue
});
this.requests.wrapSyncFunction(() => {
this.uppy.off('file-removed', removedHandler);
}, {
priority: -1
})();
return uploadPromise;
}
return _classPrivateFieldLooseBase(this, _uploadLocalFile)[_uploadLocalFile](file);
}));
}
Tus.VERSION = packageJson.version;