index.ts 21 KB

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