index.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. import BasePlugin from '@uppy/core/lib/BasePlugin.js'
  2. import * as tus from 'tus-js-client'
  3. import { Provider, RequestClient } from '@uppy/companion-client'
  4. import EventManager from '@uppy/utils/lib/EventManager'
  5. import NetworkError from '@uppy/utils/lib/NetworkError'
  6. import isNetworkError from '@uppy/utils/lib/isNetworkError'
  7. import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
  8. import hasProperty from '@uppy/utils/lib/hasProperty'
  9. import { filterNonFailedFiles, filterFilesToEmitUploadStarted } from '@uppy/utils/lib/fileFilters'
  10. import getFingerprint from './getFingerprint.js'
  11. import packageJson from '../package.json'
  12. /** @typedef {import('..').TusOptions} TusOptions */
  13. /** @typedef {import('tus-js-client').UploadOptions} RawTusOptions */
  14. /** @typedef {import('@uppy/core').Uppy} Uppy */
  15. /** @typedef {import('@uppy/core').UppyFile} UppyFile */
  16. /** @typedef {import('@uppy/core').FailedUppyFile<{}>} FailedUppyFile */
  17. /**
  18. * Extracted from https://github.com/tus/tus-js-client/blob/master/lib/upload.js#L13
  19. * excepted we removed 'fingerprint' key to avoid adding more dependencies
  20. *
  21. * @type {RawTusOptions}
  22. */
  23. const tusDefaultOptions = {
  24. endpoint: '',
  25. uploadUrl: null,
  26. metadata: {},
  27. uploadSize: null,
  28. onProgress: null,
  29. onChunkComplete: null,
  30. onSuccess: null,
  31. onError: null,
  32. overridePatchMethod: false,
  33. headers: {},
  34. addRequestId: false,
  35. chunkSize: Infinity,
  36. retryDelays: [100, 1000, 3000, 5000],
  37. parallelUploads: 1,
  38. removeFingerprintOnSuccess: false,
  39. uploadLengthDeferred: false,
  40. uploadDataDuringCreation: false,
  41. }
  42. /**
  43. * Tus resumable file uploader
  44. */
  45. export default class Tus extends BasePlugin {
  46. static VERSION = packageJson.version
  47. #retryDelayIterator
  48. /**
  49. * @param {Uppy} uppy
  50. * @param {TusOptions} opts
  51. */
  52. constructor (uppy, opts) {
  53. super(uppy, opts)
  54. this.type = 'uploader'
  55. this.id = this.opts.id || 'Tus'
  56. this.title = 'Tus'
  57. // set default options
  58. const defaultOptions = {
  59. useFastRemoteRetry: true,
  60. limit: 20,
  61. retryDelays: tusDefaultOptions.retryDelays,
  62. withCredentials: false,
  63. }
  64. // merge default options with the ones set by user
  65. /** @type {import("..").TusOptions} */
  66. this.opts = { ...defaultOptions, ...opts }
  67. if (opts?.allowedMetaFields === undefined && 'metaFields' in this.opts) {
  68. throw new Error('The `metaFields` option has been renamed to `allowedMetaFields`.')
  69. }
  70. if ('autoRetry' in opts) {
  71. throw new Error('The `autoRetry` option was deprecated and has been removed.')
  72. }
  73. /**
  74. * Simultaneous upload limiting is shared across all uploads with this plugin.
  75. *
  76. * @type {RateLimitedQueue}
  77. */
  78. this.requests = this.opts.rateLimitedQueue ?? new RateLimitedQueue(this.opts.limit)
  79. this.#retryDelayIterator = this.opts.retryDelays?.values()
  80. this.uploaders = Object.create(null)
  81. this.uploaderEvents = Object.create(null)
  82. this.handleResetProgress = this.handleResetProgress.bind(this)
  83. }
  84. handleResetProgress () {
  85. const files = { ...this.uppy.getState().files }
  86. Object.keys(files).forEach((fileID) => {
  87. // Only clone the file object if it has a Tus `uploadUrl` attached.
  88. if (files[fileID].tus && files[fileID].tus.uploadUrl) {
  89. const tusState = { ...files[fileID].tus }
  90. delete tusState.uploadUrl
  91. files[fileID] = { ...files[fileID], tus: tusState }
  92. }
  93. })
  94. this.uppy.setState({ files })
  95. }
  96. /**
  97. * Clean up all references for a file's upload: the tus.Upload instance,
  98. * any events related to the file, and the Companion WebSocket connection.
  99. *
  100. * @param {string} fileID
  101. */
  102. resetUploaderReferences (fileID, opts = {}) {
  103. if (this.uploaders[fileID]) {
  104. const uploader = this.uploaders[fileID]
  105. uploader.abort()
  106. if (opts.abort) {
  107. uploader.abort(true)
  108. }
  109. this.uploaders[fileID] = null
  110. }
  111. if (this.uploaderEvents[fileID]) {
  112. this.uploaderEvents[fileID].remove()
  113. this.uploaderEvents[fileID] = null
  114. }
  115. }
  116. /**
  117. * Create a new Tus upload.
  118. *
  119. * A lot can happen during an upload, so this is quite hard to follow!
  120. * - First, the upload is started. If the file was already paused by the time the upload starts, nothing should happen.
  121. * If the `limit` option is used, the upload must be queued onto the `this.requests` queue.
  122. * When an upload starts, we store the tus.Upload instance, and an EventManager instance that manages the event listeners
  123. * for pausing, cancellation, removal, etc.
  124. * - While the upload is in progress, it may be paused or cancelled.
  125. * Pausing aborts the underlying tus.Upload, and removes the upload from the `this.requests` queue. All other state is
  126. * maintained.
  127. * Cancelling removes the upload from the `this.requests` queue, and completely aborts the upload-- the `tus.Upload`
  128. * instance is aborted and discarded, the EventManager instance is destroyed (removing all listeners).
  129. * Resuming the upload uses the `this.requests` queue as well, to prevent selectively pausing and resuming uploads from
  130. * bypassing the limit.
  131. * - After completing an upload, the tus.Upload and EventManager instances are cleaned up, and the upload is marked as done
  132. * in the `this.requests` queue.
  133. * - When an upload completed with an error, the same happens as on successful completion, but the `upload()` promise is
  134. * rejected.
  135. *
  136. * When working on this function, keep in mind:
  137. * - When an upload is completed or cancelled for any reason, the tus.Upload and EventManager instances need to be cleaned
  138. * up using this.resetUploaderReferences().
  139. * - When an upload is cancelled or paused, for any reason, it needs to be removed from the `this.requests` queue using
  140. * `queuedRequest.abort()`.
  141. * - When an upload is completed for any reason, including errors, it needs to be marked as such using
  142. * `queuedRequest.done()`.
  143. * - When an upload is started or resumed, it needs to go through the `this.requests` queue. The `queuedRequest` variable
  144. * must be updated so the other uses of it are valid.
  145. * - Before replacing the `queuedRequest` variable, the previous `queuedRequest` must be aborted, else it will keep taking
  146. * up a spot in the queue.
  147. *
  148. * @param {UppyFile} file for use with upload
  149. * @returns {Promise<void>}
  150. */
  151. #uploadLocalFile (file) {
  152. this.resetUploaderReferences(file.id)
  153. // Create a new tus upload
  154. return new Promise((resolve, reject) => {
  155. let queuedRequest
  156. let qRequest
  157. let upload
  158. const opts = {
  159. ...this.opts,
  160. ...(file.tus || {}),
  161. }
  162. if (typeof opts.headers === 'function') {
  163. opts.headers = opts.headers(file)
  164. }
  165. /** @type {RawTusOptions} */
  166. const uploadOptions = {
  167. ...tusDefaultOptions,
  168. ...opts,
  169. }
  170. // We override tus fingerprint to uppy’s `file.id`, since the `file.id`
  171. // now also includes `relativePath` for files added from folders.
  172. // This means you can add 2 identical files, if one is in folder a,
  173. // the other in folder b.
  174. uploadOptions.fingerprint = getFingerprint(file)
  175. uploadOptions.onBeforeRequest = (req) => {
  176. const xhr = req.getUnderlyingObject()
  177. xhr.withCredentials = !!opts.withCredentials
  178. let userProvidedPromise
  179. if (typeof opts.onBeforeRequest === 'function') {
  180. userProvidedPromise = opts.onBeforeRequest(req, file)
  181. }
  182. if (hasProperty(queuedRequest, 'shouldBeRequeued')) {
  183. if (!queuedRequest.shouldBeRequeued) return Promise.reject()
  184. let done
  185. const p = new Promise((res) => { // eslint-disable-line promise/param-names
  186. done = res
  187. })
  188. queuedRequest = this.requests.run(() => {
  189. if (file.isPaused) {
  190. queuedRequest.abort()
  191. }
  192. done()
  193. return () => {}
  194. })
  195. // If the request has been requeued because it was rate limited by the
  196. // remote server, we want to wait for `RateLimitedQueue` to dispatch
  197. // the re-try request.
  198. // Therefore we create a promise that the queue will resolve when
  199. // enough time has elapsed to expect not to be rate-limited again.
  200. // This means we can hold the Tus retry here with a `Promise.all`,
  201. // together with the returned value of the user provided
  202. // `onBeforeRequest` option callback (in case it returns a promise).
  203. return Promise.all([p, userProvidedPromise])
  204. }
  205. return userProvidedPromise
  206. }
  207. uploadOptions.onError = (err) => {
  208. this.uppy.log(err)
  209. const xhr = err.originalRequest ? err.originalRequest.getUnderlyingObject() : null
  210. if (isNetworkError(xhr)) {
  211. // eslint-disable-next-line no-param-reassign
  212. err = new NetworkError(err, xhr)
  213. }
  214. this.resetUploaderReferences(file.id)
  215. queuedRequest?.abort()
  216. this.uppy.emit('upload-error', file, err)
  217. reject(err)
  218. }
  219. uploadOptions.onProgress = (bytesUploaded, bytesTotal) => {
  220. this.onReceiveUploadUrl(file, upload.url)
  221. this.uppy.emit('upload-progress', file, {
  222. uploader: this,
  223. bytesUploaded,
  224. bytesTotal,
  225. })
  226. }
  227. uploadOptions.onSuccess = () => {
  228. const uploadResp = {
  229. uploadURL: upload.url,
  230. }
  231. this.resetUploaderReferences(file.id)
  232. queuedRequest.done()
  233. this.uppy.emit('upload-success', file, uploadResp)
  234. if (upload.url) {
  235. this.uppy.log(`Download ${upload.file.name} from ${upload.url}`)
  236. }
  237. resolve(upload)
  238. }
  239. const defaultOnShouldRetry = (err) => {
  240. const status = err?.originalResponse?.getStatus()
  241. if (status === 429) {
  242. // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
  243. if (!this.requests.isPaused) {
  244. const next = this.#retryDelayIterator?.next()
  245. if (next == null || next.done) {
  246. return false
  247. }
  248. this.requests.rateLimit(next.value)
  249. }
  250. } else if (status > 400 && status < 500 && status !== 409 && status !== 423) {
  251. // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
  252. // HTTP 409 Conflict (happens if the Upload-Offset header does not match the one on the server)
  253. // HTTP 423 Locked (happens when a paused download is resumed too quickly)
  254. return false
  255. } else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
  256. // The navigator is offline, let's wait for it to come back online.
  257. if (!this.requests.isPaused) {
  258. this.requests.pause()
  259. window.addEventListener('online', () => {
  260. this.requests.resume()
  261. }, { once: true })
  262. }
  263. }
  264. queuedRequest.abort()
  265. queuedRequest = {
  266. shouldBeRequeued: true,
  267. abort () {
  268. this.shouldBeRequeued = false
  269. },
  270. done () {
  271. throw new Error('Cannot mark a queued request as done: this indicates a bug')
  272. },
  273. fn () {
  274. throw new Error('Cannot run a queued request: this indicates a bug')
  275. },
  276. }
  277. return true
  278. }
  279. if (opts.onShouldRetry != null) {
  280. uploadOptions.onShouldRetry = (...args) => opts.onShouldRetry(...args, defaultOnShouldRetry)
  281. } else {
  282. uploadOptions.onShouldRetry = defaultOnShouldRetry
  283. }
  284. const copyProp = (obj, srcProp, destProp) => {
  285. if (hasProperty(obj, srcProp) && !hasProperty(obj, destProp)) {
  286. // eslint-disable-next-line no-param-reassign
  287. obj[destProp] = obj[srcProp]
  288. }
  289. }
  290. /** @type {Record<string, string>} */
  291. const meta = {}
  292. const allowedMetaFields = Array.isArray(opts.allowedMetaFields)
  293. ? opts.allowedMetaFields
  294. // Send along all fields by default.
  295. : Object.keys(file.meta)
  296. allowedMetaFields.forEach((item) => {
  297. meta[item] = file.meta[item]
  298. })
  299. // tusd uses metadata fields 'filetype' and 'filename'
  300. copyProp(meta, 'type', 'filetype')
  301. copyProp(meta, 'name', 'filename')
  302. uploadOptions.metadata = meta
  303. upload = new tus.Upload(file.data, uploadOptions)
  304. this.uploaders[file.id] = upload
  305. const eventManager = new EventManager(this.uppy)
  306. this.uploaderEvents[file.id] = eventManager
  307. // eslint-disable-next-line prefer-const
  308. qRequest = () => {
  309. if (!file.isPaused) {
  310. upload.start()
  311. }
  312. // Don't do anything here, the caller will take care of cancelling the upload itself
  313. // using resetUploaderReferences(). This is because resetUploaderReferences() has to be
  314. // called when this request is still in the queue, and has not been started yet, too. At
  315. // that point this cancellation function is not going to be called.
  316. // Also, we need to remove the request from the queue _without_ destroying everything
  317. // related to this upload to handle pauses.
  318. return () => {}
  319. }
  320. upload.findPreviousUploads().then((previousUploads) => {
  321. const previousUpload = previousUploads[0]
  322. if (previousUpload) {
  323. this.uppy.log(`[Tus] Resuming upload of ${file.id} started at ${previousUpload.creationTime}`)
  324. upload.resumeFromPreviousUpload(previousUpload)
  325. }
  326. })
  327. queuedRequest = this.requests.run(qRequest)
  328. eventManager.onFileRemove(file.id, (targetFileID) => {
  329. queuedRequest.abort()
  330. this.resetUploaderReferences(file.id, { abort: !!upload.url })
  331. resolve(`upload ${targetFileID} was removed`)
  332. })
  333. eventManager.onPause(file.id, (isPaused) => {
  334. queuedRequest.abort()
  335. if (isPaused) {
  336. // Remove this file from the queue so another file can start in its place.
  337. upload.abort()
  338. } else {
  339. // Resuming an upload should be queued, else you could pause and then
  340. // resume a queued upload to make it skip the queue.
  341. queuedRequest = this.requests.run(qRequest)
  342. }
  343. })
  344. eventManager.onPauseAll(file.id, () => {
  345. queuedRequest.abort()
  346. upload.abort()
  347. })
  348. eventManager.onCancelAll(file.id, ({ reason } = {}) => {
  349. if (reason === 'user') {
  350. queuedRequest.abort()
  351. this.resetUploaderReferences(file.id, { abort: !!upload.url })
  352. }
  353. resolve(`upload ${file.id} was canceled`)
  354. })
  355. eventManager.onResumeAll(file.id, () => {
  356. queuedRequest.abort()
  357. if (file.error) {
  358. upload.abort()
  359. }
  360. queuedRequest = this.requests.run(qRequest)
  361. })
  362. }).catch((err) => {
  363. this.uppy.emit('upload-error', file, err)
  364. throw err
  365. })
  366. }
  367. /**
  368. * Store the uploadUrl on the file options, so that when Golden Retriever
  369. * restores state, we will continue uploading to the correct URL.
  370. *
  371. * @param {UppyFile} file
  372. * @param {string} uploadURL
  373. */
  374. onReceiveUploadUrl (file, uploadURL) {
  375. const currentFile = this.uppy.getFile(file.id)
  376. if (!currentFile) return
  377. // Only do the update if we didn't have an upload URL yet.
  378. if (!currentFile.tus || currentFile.tus.uploadUrl !== uploadURL) {
  379. this.uppy.log('[Tus] Storing upload url')
  380. this.uppy.setFileState(currentFile.id, {
  381. tus: { ...currentFile.tus, uploadUrl: uploadURL },
  382. })
  383. }
  384. }
  385. #getCompanionClientArgs (file) {
  386. const opts = { ...this.opts }
  387. if (file.tus) {
  388. // Install file-specific upload overrides.
  389. Object.assign(opts, file.tus)
  390. }
  391. return {
  392. ...file.remote.body,
  393. endpoint: opts.endpoint,
  394. uploadUrl: opts.uploadUrl,
  395. protocol: 'tus',
  396. size: file.data.size,
  397. headers: opts.headers,
  398. metadata: file.meta,
  399. }
  400. }
  401. /**
  402. * @param {(UppyFile | FailedUppyFile)[]} files
  403. */
  404. async #uploadFiles (files) {
  405. const filesFiltered = filterNonFailedFiles(files)
  406. const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
  407. this.uppy.emit('upload-start', filesToEmit)
  408. await Promise.allSettled(filesFiltered.map((file, i) => {
  409. const current = i + 1
  410. const total = files.length
  411. if (file.isRemote) {
  412. // INFO: the url plugin needs to use RequestClient,
  413. // while others use Provider
  414. const Client = file.remote.providerOptions.provider ? Provider : RequestClient
  415. const getQueue = () => this.requests
  416. const client = new Client(this.uppy, file.remote.providerOptions, getQueue)
  417. const controller = new AbortController()
  418. const removedHandler = (removedFile) => {
  419. if (removedFile.id === file.id) controller.abort()
  420. }
  421. this.uppy.on('file-removed', removedHandler)
  422. const uploadPromise = client.uploadRemoteFile(
  423. file,
  424. this.#getCompanionClientArgs(file),
  425. { signal: controller.signal },
  426. )
  427. this.requests.wrapSyncFunction(() => {
  428. this.uppy.off('file-removed', removedHandler)
  429. }, { priority: -1 })()
  430. return uploadPromise
  431. }
  432. return this.#uploadLocalFile(file, current, total)
  433. }))
  434. }
  435. /**
  436. * @param {string[]} fileIDs
  437. */
  438. #handleUpload = async (fileIDs) => {
  439. if (fileIDs.length === 0) {
  440. this.uppy.log('[Tus] No files to upload')
  441. return
  442. }
  443. if (this.opts.limit === 0) {
  444. this.uppy.log(
  445. '[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',
  446. 'warning',
  447. )
  448. }
  449. this.uppy.log('[Tus] Uploading...')
  450. const filesToUpload = this.uppy.getFilesByIds(fileIDs)
  451. await this.#uploadFiles(filesToUpload)
  452. }
  453. install () {
  454. this.uppy.setState({
  455. capabilities: { ...this.uppy.getState().capabilities, resumableUploads: true },
  456. })
  457. this.uppy.addUploader(this.#handleUpload)
  458. this.uppy.on('reset-progress', this.handleResetProgress)
  459. }
  460. uninstall () {
  461. this.uppy.setState({
  462. capabilities: { ...this.uppy.getState().capabilities, resumableUploads: false },
  463. })
  464. this.uppy.removeUploader(this.#handleUpload)
  465. }
  466. }