123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697 |
- const BasePlugin = require('@uppy/core/lib/BasePlugin')
- const tus = require('tus-js-client')
- const { Provider, RequestClient, Socket } = require('@uppy/companion-client')
- const emitSocketProgress = require('@uppy/utils/lib/emitSocketProgress')
- const getSocketHost = require('@uppy/utils/lib/getSocketHost')
- const settle = require('@uppy/utils/lib/settle')
- const EventTracker = require('@uppy/utils/lib/EventTracker')
- const NetworkError = require('@uppy/utils/lib/NetworkError')
- const isNetworkError = require('@uppy/utils/lib/isNetworkError')
- const { RateLimitedQueue } = require('@uppy/utils/lib/RateLimitedQueue')
- const hasProperty = require('@uppy/utils/lib/hasProperty')
- const getFingerprint = require('./getFingerprint')
- /** @typedef {import('..').TusOptions} TusOptions */
- /** @typedef {import('tus-js-client').UploadOptions} RawTusOptions */
- /** @typedef {import('@uppy/core').Uppy} Uppy */
- /** @typedef {import('@uppy/core').UppyFile} UppyFile */
- /** @typedef {import('@uppy/core').FailedUppyFile<{}>} FailedUppyFile */
- /**
- * 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
- *
- * @type {RawTusOptions}
- */
- const tusDefaultOptions = {
- endpoint: '',
- uploadUrl: null,
- metadata: {},
- uploadSize: null,
- onProgress: null,
- onChunkComplete: null,
- onSuccess: null,
- onError: null,
- overridePatchMethod: false,
- headers: {},
- addRequestId: false,
- chunkSize: Infinity,
- retryDelays: [0, 1000, 3000, 5000],
- parallelUploads: 1,
- removeFingerprintOnSuccess: false,
- uploadLengthDeferred: false,
- uploadDataDuringCreation: false,
- }
- /**
- * Tus resumable file uploader
- */
- module.exports = class Tus extends BasePlugin {
- static VERSION = require('../package.json').version
- /**
- * @param {Uppy} uppy
- * @param {TusOptions} opts
- */
- constructor (uppy, opts) {
- super(uppy, opts)
- this.type = 'uploader'
- this.id = this.opts.id || 'Tus'
- this.title = 'Tus'
- // set default options
- const defaultOptions = {
- useFastRemoteRetry: true,
- limit: 5,
- retryDelays: [0, 1000, 3000, 5000],
- withCredentials: false,
- }
- // merge default options with the ones set by user
- /** @type {import("..").TusOptions} */
- this.opts = { ...defaultOptions, ...opts }
- 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 = new RateLimitedQueue(this.opts.limit)
- this.uploaders = Object.create(null)
- this.uploaderEvents = Object.create(null)
- this.uploaderSockets = Object.create(null)
- this.handleResetProgress = this.handleResetProgress.bind(this)
- this.handleUpload = this.handleUpload.bind(this)
- }
- handleResetProgress () {
- const files = { ...this.uppy.getState().files }
- Object.keys(files).forEach((fileID) => {
- // Only clone the file object if it has a Tus `uploadUrl` attached.
- if (files[fileID].tus && files[fileID].tus.uploadUrl) {
- const tusState = { ...files[fileID].tus }
- delete tusState.uploadUrl
- files[fileID] = { ...files[fileID], tus: tusState }
- }
- })
- this.uppy.setState({ files })
- }
- /**
- * Clean up all references for a file's upload: the tus.Upload instance,
- * any events related to the file, and the Companion WebSocket connection.
- *
- * @param {string} fileID
- */
- resetUploaderReferences (fileID, opts = {}) {
- if (this.uploaders[fileID]) {
- const uploader = this.uploaders[fileID]
- uploader.abort()
- if (opts.abort) {
- uploader.abort(true)
- }
- this.uploaders[fileID] = null
- }
- if (this.uploaderEvents[fileID]) {
- this.uploaderEvents[fileID].remove()
- this.uploaderEvents[fileID] = null
- }
- if (this.uploaderSockets[fileID]) {
- this.uploaderSockets[fileID].close()
- this.uploaderSockets[fileID] = null
- }
- }
- /**
- * 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 EventTracker 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 EventTracker 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 EventTracker 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 EventTracker 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.
- *
- * @param {UppyFile} file for use with upload
- * @param {number} current file in a queue
- * @param {number} total number of files in a queue
- * @returns {Promise<void>}
- */
- upload (file) {
- this.resetUploaderReferences(file.id)
- // Create a new tus upload
- return new Promise((resolve, reject) => {
- this.uppy.emit('upload-started', file)
- const opts = {
- ...this.opts,
- ...(file.tus || {}),
- }
- if (typeof opts.headers === 'function') {
- opts.headers = opts.headers(file)
- }
- /** @type {RawTusOptions} */
- const uploadOptions = {
- ...tusDefaultOptions,
- ...opts,
- }
- // 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 = (req) => {
- const xhr = req.getUnderlyingObject()
- xhr.withCredentials = !!opts.withCredentials
- if (typeof opts.onBeforeRequest === 'function') {
- opts.onBeforeRequest(req)
- }
- }
- uploadOptions.onError = (err) => {
- this.uppy.log(err)
- const xhr = err.originalRequest ? err.originalRequest.getUnderlyingObject() : null
- if (isNetworkError(xhr)) {
- err = new NetworkError(err, xhr)
- }
- this.resetUploaderReferences(file.id)
- queuedRequest.done()
- this.uppy.emit('upload-error', file, err)
- reject(err)
- }
- uploadOptions.onProgress = (bytesUploaded, bytesTotal) => {
- this.onReceiveUploadUrl(file, upload.url)
- this.uppy.emit('upload-progress', file, {
- uploader: this,
- bytesUploaded,
- bytesTotal,
- })
- }
- uploadOptions.onSuccess = () => {
- const uploadResp = {
- uploadURL: upload.url,
- }
- this.resetUploaderReferences(file.id)
- queuedRequest.done()
- this.uppy.emit('upload-success', file, uploadResp)
- if (upload.url) {
- this.uppy.log(`Download ${upload.file.name} from ${upload.url}`)
- }
- resolve(upload)
- }
- const copyProp = (obj, srcProp, destProp) => {
- if (hasProperty(obj, srcProp) && !hasProperty(obj, destProp)) {
- obj[destProp] = obj[srcProp]
- }
- }
- /** @type {Record<string, string>} */
- const meta = {}
- const metaFields = Array.isArray(opts.metaFields)
- ? opts.metaFields
- // Send along all fields by default.
- : Object.keys(file.meta)
- metaFields.forEach((item) => {
- meta[item] = file.meta[item]
- })
- // tusd uses metadata fields 'filetype' and 'filename'
- copyProp(meta, 'type', 'filetype')
- copyProp(meta, 'name', 'filename')
- uploadOptions.metadata = meta
- const upload = new tus.Upload(file.data, uploadOptions)
- this.uploaders[file.id] = upload
- this.uploaderEvents[file.id] = new EventTracker(this.uppy)
- 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)
- }
- })
- let queuedRequest = this.requests.run(() => {
- 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 () => {}
- })
- this.onFileRemove(file.id, (targetFileID) => {
- queuedRequest.abort()
- this.resetUploaderReferences(file.id, { abort: !!upload.url })
- resolve(`upload ${targetFileID} was removed`)
- })
- this.onPause(file.id, (isPaused) => {
- if (isPaused) {
- // Remove this file from the queue so another file can start in its place.
- queuedRequest.abort()
- 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.abort()
- queuedRequest = this.requests.run(() => {
- upload.start()
- return () => {}
- })
- }
- })
- this.onPauseAll(file.id, () => {
- queuedRequest.abort()
- upload.abort()
- })
- this.onCancelAll(file.id, () => {
- queuedRequest.abort()
- this.resetUploaderReferences(file.id, { abort: !!upload.url })
- resolve(`upload ${file.id} was canceled`)
- })
- this.onResumeAll(file.id, () => {
- queuedRequest.abort()
- if (file.error) {
- upload.abort()
- }
- queuedRequest = this.requests.run(() => {
- upload.start()
- return () => {}
- })
- })
- }).catch((err) => {
- this.uppy.emit('upload-error', file, err)
- throw err
- })
- }
- /**
- * @param {UppyFile} file for use with upload
- * @param {number} current file in a queue
- * @param {number} total number of files in a queue
- * @returns {Promise<void>}
- */
- uploadRemote (file) {
- this.resetUploaderReferences(file.id)
- const opts = { ...this.opts }
- if (file.tus) {
- // Install file-specific upload overrides.
- Object.assign(opts, file.tus)
- }
- this.uppy.emit('upload-started', file)
- this.uppy.log(file.remote.url)
- if (file.serverToken) {
- return this.connectToServerSocket(file)
- }
- return new Promise((resolve, reject) => {
- const Client = file.remote.providerOptions.provider ? Provider : RequestClient
- const client = new Client(this.uppy, file.remote.providerOptions)
- // !! cancellation is NOT supported at this stage yet
- client.post(file.remote.url, {
- ...file.remote.body,
- endpoint: opts.endpoint,
- uploadUrl: opts.uploadUrl,
- protocol: 'tus',
- size: file.data.size,
- headers: opts.headers,
- metadata: file.meta,
- }).then((res) => {
- this.uppy.setFileState(file.id, { serverToken: res.token })
- file = this.uppy.getFile(file.id)
- return this.connectToServerSocket(file)
- }).then(() => {
- resolve()
- }).catch((err) => {
- this.uppy.emit('upload-error', file, err)
- reject(err)
- })
- })
- }
- /**
- * See the comment on the upload() method.
- *
- * Additionally, when an upload is removed, completed, or cancelled, we need to close the WebSocket connection. This is
- * handled by the resetUploaderReferences() function, so the same guidelines apply as in upload().
- *
- * @param {UppyFile} file
- */
- connectToServerSocket (file) {
- return new Promise((resolve, reject) => {
- const token = file.serverToken
- const host = getSocketHost(file.remote.companionUrl)
- const socket = new Socket({ target: `${host}/api/${token}`, autoOpen: false })
- this.uploaderSockets[file.id] = socket
- this.uploaderEvents[file.id] = new EventTracker(this.uppy)
- this.onFileRemove(file.id, () => {
- queuedRequest.abort()
- socket.send('cancel', {})
- this.resetUploaderReferences(file.id)
- resolve(`upload ${file.id} was removed`)
- })
- this.onPause(file.id, (isPaused) => {
- if (isPaused) {
- // Remove this file from the queue so another file can start in its place.
- queuedRequest.abort()
- socket.send('pause', {})
- } else {
- // Resuming an upload should be queued, else you could pause and then
- // resume a queued upload to make it skip the queue.
- queuedRequest.abort()
- queuedRequest = this.requests.run(() => {
- socket.send('resume', {})
- return () => {}
- })
- }
- })
- this.onPauseAll(file.id, () => {
- queuedRequest.abort()
- socket.send('pause', {})
- })
- this.onCancelAll(file.id, () => {
- queuedRequest.abort()
- socket.send('cancel', {})
- this.resetUploaderReferences(file.id)
- resolve(`upload ${file.id} was canceled`)
- })
- this.onResumeAll(file.id, () => {
- queuedRequest.abort()
- if (file.error) {
- socket.send('pause', {})
- }
- queuedRequest = this.requests.run(() => {
- socket.send('resume', {})
- return () => {}
- })
- })
- this.onRetry(file.id, () => {
- // Only do the retry if the upload is actually in progress;
- // else we could try to send these messages when the upload is still queued.
- // We may need a better check for this since the socket may also be closed
- // for other reasons, like network failures.
- if (socket.isOpen) {
- socket.send('pause', {})
- socket.send('resume', {})
- }
- })
- this.onRetryAll(file.id, () => {
- // See the comment in the onRetry() call
- if (socket.isOpen) {
- socket.send('pause', {})
- socket.send('resume', {})
- }
- })
- socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
- socket.on('error', (errData) => {
- const { message } = errData.error
- const error = Object.assign(new Error(message), { cause: errData.error })
- // If the remote retry optimisation should not be used,
- // close the socket—this will tell companion to clear state and delete the file.
- if (!this.opts.useFastRemoteRetry) {
- this.resetUploaderReferences(file.id)
- // Remove the serverToken so that a new one will be created for the retry.
- this.uppy.setFileState(file.id, {
- serverToken: null,
- })
- } else {
- socket.close()
- }
- this.uppy.emit('upload-error', file, error)
- queuedRequest.done()
- reject(error)
- })
- socket.on('success', (data) => {
- const uploadResp = {
- uploadURL: data.url,
- }
- this.uppy.emit('upload-success', file, uploadResp)
- this.resetUploaderReferences(file.id)
- queuedRequest.done()
- resolve()
- })
- let queuedRequest = this.requests.run(() => {
- socket.open()
- if (file.isPaused) {
- socket.send('pause', {})
- }
- // 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 () => {}
- })
- })
- }
- /**
- * Store the uploadUrl on the file options, so that when Golden Retriever
- * restores state, we will continue uploading to the correct URL.
- *
- * @param {UppyFile} file
- * @param {string} uploadURL
- */
- 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 },
- })
- }
- }
- /**
- * @param {string} fileID
- * @param {function(string): void} cb
- */
- onFileRemove (fileID, cb) {
- this.uploaderEvents[fileID].on('file-removed', (file) => {
- if (fileID === file.id) cb(file.id)
- })
- }
- /**
- * @param {string} fileID
- * @param {function(boolean): void} cb
- */
- onPause (fileID, cb) {
- this.uploaderEvents[fileID].on('upload-pause', (targetFileID, isPaused) => {
- if (fileID === targetFileID) {
- // const isPaused = this.uppy.pauseResume(fileID)
- cb(isPaused)
- }
- })
- }
- /**
- * @param {string} fileID
- * @param {function(): void} cb
- */
- onRetry (fileID, cb) {
- this.uploaderEvents[fileID].on('upload-retry', (targetFileID) => {
- if (fileID === targetFileID) {
- cb()
- }
- })
- }
- /**
- * @param {string} fileID
- * @param {function(): void} cb
- */
- onRetryAll (fileID, cb) {
- this.uploaderEvents[fileID].on('retry-all', () => {
- if (!this.uppy.getFile(fileID)) return
- cb()
- })
- }
- /**
- * @param {string} fileID
- * @param {function(): void} cb
- */
- onPauseAll (fileID, cb) {
- this.uploaderEvents[fileID].on('pause-all', () => {
- if (!this.uppy.getFile(fileID)) return
- cb()
- })
- }
- /**
- * @param {string} fileID
- * @param {function(): void} cb
- */
- onCancelAll (fileID, cb) {
- this.uploaderEvents[fileID].on('cancel-all', () => {
- if (!this.uppy.getFile(fileID)) return
- cb()
- })
- }
- /**
- * @param {string} fileID
- * @param {function(): void} cb
- */
- onResumeAll (fileID, cb) {
- this.uploaderEvents[fileID].on('resume-all', () => {
- if (!this.uppy.getFile(fileID)) return
- cb()
- })
- }
- /**
- * @param {(UppyFile | FailedUppyFile)[]} files
- */
- uploadFiles (files) {
- const promises = files.map((file, i) => {
- const current = i + 1
- const total = files.length
- if ('error' in file && file.error) {
- return Promise.reject(new Error(file.error))
- } if (file.isRemote) {
- // We emit upload-started here, so that it's also emitted for files
- // that have to wait due to the `limit` option.
- // Don't double-emit upload-started for Golden Retriever-restored files that were already started
- if (!file.progress.uploadStarted || !file.isRestored) {
- this.uppy.emit('upload-started', file)
- }
- return this.uploadRemote(file, current, total)
- }
- // Don't double-emit upload-started for Golden Retriever-restored files that were already started
- if (!file.progress.uploadStarted || !file.isRestored) {
- this.uppy.emit('upload-started', file)
- }
- return this.upload(file, current, total)
- })
- return settle(promises)
- }
- /**
- * @param {string[]} fileIDs
- */
- handleUpload (fileIDs) {
- if (fileIDs.length === 0) {
- this.uppy.log('[Tus] No files to upload')
- return Promise.resolve()
- }
- 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 = fileIDs.map((fileID) => this.uppy.getFile(fileID))
- return this.uploadFiles(filesToUpload)
- .then(() => null)
- }
- install () {
- this.uppy.setState({
- capabilities: { ...this.uppy.getState().capabilities, resumableUploads: true },
- })
- this.uppy.addUploader(this.handleUpload)
- this.uppy.on('reset-progress', this.handleResetProgress)
- }
- uninstall () {
- this.uppy.setState({
- capabilities: { ...this.uppy.getState().capabilities, resumableUploads: false },
- })
- this.uppy.removeUploader(this.handleUpload)
- }
- }
|