index.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. import hasProperty from '@uppy/utils/lib/hasProperty'
  2. import ErrorWithCause from '@uppy/utils/lib/ErrorWithCause'
  3. import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
  4. import BasePlugin from '@uppy/core/lib/BasePlugin.js'
  5. import Tus from '@uppy/tus'
  6. import Assembly from './Assembly.js'
  7. import Client from './Client.js'
  8. import AssemblyOptions, { validateParams } from './AssemblyOptions.js'
  9. import AssemblyWatcher from './AssemblyWatcher.js'
  10. import locale from './locale.js'
  11. import packageJson from '../package.json'
  12. const sendErrorToConsole = originalErr => err => {
  13. const error = new ErrorWithCause('Failed to send error to the client', { cause: err })
  14. // eslint-disable-next-line no-console
  15. console.error(error, originalErr)
  16. }
  17. const COMPANION_URL = 'https://api2.transloadit.com/companion'
  18. // Regex matching acceptable postMessage() origins for authentication feedback from companion.
  19. const COMPANION_ALLOWED_HOSTS = /\.transloadit\.com$/
  20. // Regex used to check if a Companion address is run by Transloadit.
  21. const TL_COMPANION = /https?:\/\/api2(?:-\w+)?\.transloadit\.com\/companion/
  22. /**
  23. * Upload files to Transloadit using Tus.
  24. */
  25. export default class Transloadit extends BasePlugin {
  26. static VERSION = packageJson.version
  27. /** @deprecated use `import { COMPANION_URL } from '@uppy/transloadit'` instead. */
  28. static COMPANION = COMPANION_URL
  29. /** @deprecated use `import { COMPANION_ALLOWED_HOSTS } from '@uppy/transloadit'` instead. */
  30. static COMPANION_PATTERN = COMPANION_ALLOWED_HOSTS
  31. #rateLimitedQueue
  32. constructor (uppy, opts) {
  33. super(uppy, opts)
  34. this.type = 'uploader'
  35. this.id = this.opts.id || 'Transloadit'
  36. this.title = 'Transloadit'
  37. this.defaultLocale = locale
  38. const defaultOptions = {
  39. service: 'https://api2.transloadit.com',
  40. errorReporting: true,
  41. waitForEncoding: false,
  42. waitForMetadata: false,
  43. alwaysRunAssembly: false,
  44. importFromUploadURLs: false,
  45. /** @deprecated use `assemblyOptions` instead */
  46. signature: null,
  47. /** @deprecated use `assemblyOptions` instead */
  48. params: null,
  49. /** @deprecated use `assemblyOptions` instead */
  50. fields: null,
  51. /** @deprecated use `assemblyOptions` instead */
  52. getAssemblyOptions: null,
  53. limit: 20,
  54. retryDelays: [7_000, 10_000, 15_000, 20_000],
  55. }
  56. this.opts = { ...defaultOptions, ...opts }
  57. // TODO: remove this fallback in the next major
  58. this.opts.assemblyOptions ??= this.opts.getAssemblyOptions ?? {
  59. params: this.opts.params,
  60. signature: this.opts.signature,
  61. fields: this.opts.fields,
  62. }
  63. // TODO: remove this check in the next major (validating params when creating the assembly should be enough)
  64. if (opts?.params != null && opts.getAssemblyOptions == null && opts.assemblyOptions == null) {
  65. validateParams(this.opts.assemblyOptions.params)
  66. }
  67. this.#rateLimitedQueue = new RateLimitedQueue(this.opts.limit)
  68. this.i18nInit()
  69. this.client = new Client({
  70. service: this.opts.service,
  71. client: this.#getClientVersion(),
  72. errorReporting: this.opts.errorReporting,
  73. rateLimitedQueue: this.#rateLimitedQueue,
  74. })
  75. // Contains Assembly instances for in-progress Assemblies.
  76. this.activeAssemblies = {}
  77. // Contains a mapping of uploadID to AssemblyWatcher
  78. this.assemblyWatchers = {}
  79. // Contains a file IDs that have completed postprocessing before the upload
  80. // they belong to has entered the postprocess stage.
  81. this.completedFiles = Object.create(null)
  82. }
  83. #getClientVersion () {
  84. const list = [
  85. `uppy-core:${this.uppy.constructor.VERSION}`,
  86. `uppy-transloadit:${this.constructor.VERSION}`,
  87. `uppy-tus:${Tus.VERSION}`,
  88. ]
  89. const addPluginVersion = (pluginName, versionName) => {
  90. const plugin = this.uppy.getPlugin(pluginName)
  91. if (plugin) {
  92. list.push(`${versionName}:${plugin.constructor.VERSION}`)
  93. }
  94. }
  95. if (this.opts.importFromUploadURLs) {
  96. addPluginVersion('XHRUpload', 'uppy-xhr-upload')
  97. addPluginVersion('AwsS3', 'uppy-aws-s3')
  98. addPluginVersion('AwsS3Multipart', 'uppy-aws-s3-multipart')
  99. }
  100. addPluginVersion('Dropbox', 'uppy-dropbox')
  101. addPluginVersion('Box', 'uppy-box')
  102. addPluginVersion('Facebook', 'uppy-facebook')
  103. addPluginVersion('GoogleDrive', 'uppy-google-drive')
  104. addPluginVersion('Instagram', 'uppy-instagram')
  105. addPluginVersion('OneDrive', 'uppy-onedrive')
  106. addPluginVersion('Zoom', 'uppy-zoom')
  107. addPluginVersion('Url', 'uppy-url')
  108. return list.join(',')
  109. }
  110. /**
  111. * Attach metadata to files to configure the Tus plugin to upload to Transloadit.
  112. * Also use Transloadit's Companion
  113. *
  114. * See: https://github.com/tus/tusd/wiki/Uploading-to-Transloadit-using-tus#uploading-using-tus
  115. *
  116. * @param {object} file
  117. * @param {object} status
  118. */
  119. #attachAssemblyMetadata (file, status) {
  120. // Add the metadata parameters Transloadit needs.
  121. const meta = {
  122. ...file.meta,
  123. assembly_url: status.assembly_url,
  124. filename: file.name,
  125. fieldname: 'file',
  126. }
  127. // Add Assembly-specific Tus endpoint.
  128. const tus = {
  129. ...file.tus,
  130. endpoint: status.tus_url,
  131. // Include X-Request-ID headers for better debugging.
  132. addRequestId: true,
  133. }
  134. // Set Companion location. We only add this, if 'file' has the attribute
  135. // remote, because this is the criteria to identify remote files.
  136. // We only replace the hostname for Transloadit's companions, so that
  137. // people can also self-host them while still using Transloadit for encoding.
  138. let { remote } = file
  139. if (file.remote && TL_COMPANION.test(file.remote.companionUrl)) {
  140. const newHost = status.companion_url
  141. .replace(/\/$/, '')
  142. const path = file.remote.url
  143. .replace(file.remote.companionUrl, '')
  144. .replace(/^\//, '')
  145. remote = {
  146. ...file.remote,
  147. companionUrl: newHost,
  148. url: `${newHost}/${path}`,
  149. }
  150. }
  151. // Store the Assembly ID this file is in on the file under the `transloadit` key.
  152. const newFile = {
  153. ...file,
  154. transloadit: {
  155. assembly: status.assembly_id,
  156. },
  157. }
  158. // Only configure the Tus plugin if we are uploading straight to Transloadit (the default).
  159. if (!this.opts.importFromUploadURLs) {
  160. Object.assign(newFile, { meta, tus, remote })
  161. }
  162. return newFile
  163. }
  164. #createAssembly (fileIDs, uploadID, assemblyOptions) {
  165. this.uppy.log('[Transloadit] Create Assembly')
  166. return this.client.createAssembly({
  167. ...assemblyOptions,
  168. expectedFiles: fileIDs.length,
  169. }).then(async (newAssembly) => {
  170. const files = this.uppy.getFiles().filter(({ id }) => fileIDs.includes(id))
  171. if (files.length !== fileIDs.length) {
  172. if (files.length === 0) {
  173. // All files have been removed, cancelling.
  174. await this.client.cancelAssembly(newAssembly)
  175. return null
  176. }
  177. // At least one file has been removed.
  178. await this.client.updateNumberOfFilesInAssembly(newAssembly, files.length)
  179. }
  180. const assembly = new Assembly(newAssembly, this.#rateLimitedQueue)
  181. const { status } = assembly
  182. const assemblyID = status.assembly_id
  183. const { assemblies, uploadsAssemblies } = this.getPluginState()
  184. this.setPluginState({
  185. // Store the Assembly status.
  186. assemblies: {
  187. ...assemblies,
  188. [assemblyID]: status,
  189. },
  190. // Store the list of Assemblies related to this upload.
  191. uploadsAssemblies: {
  192. ...uploadsAssemblies,
  193. [uploadID]: [
  194. ...uploadsAssemblies[uploadID],
  195. assemblyID,
  196. ],
  197. },
  198. })
  199. const updatedFiles = {}
  200. files.forEach((file) => {
  201. updatedFiles[file.id] = this.#attachAssemblyMetadata(file, status)
  202. })
  203. this.uppy.setState({
  204. files: {
  205. ...this.uppy.getState().files,
  206. ...updatedFiles,
  207. },
  208. })
  209. // TODO: this should not live inside a `file-removed` event but somewhere more deterministic.
  210. // Such as inside the function where the assembly has succeeded or cancelled.
  211. // For the use case of cancelling the assembly when needed, we should try to do that with just `cancel-all`.
  212. const fileRemovedHandler = (fileRemoved, reason) => {
  213. // If the assembly has successfully completed, we do not need these checks.
  214. // Otherwise we may cancel an assembly after it already succeeded
  215. if (assembly.status?.ok === 'ASSEMBLY_COMPLETED') {
  216. this.uppy.off('file-removed', fileRemovedHandler)
  217. return
  218. }
  219. if (reason === 'cancel-all') {
  220. assembly.close()
  221. this.uppy.off('file-removed', fileRemovedHandler)
  222. } else if (fileRemoved.id in updatedFiles) {
  223. delete updatedFiles[fileRemoved.id]
  224. const nbOfRemainingFiles = Object.keys(updatedFiles).length
  225. if (nbOfRemainingFiles === 0) {
  226. assembly.close()
  227. this.#cancelAssembly(newAssembly).catch(() => { /* ignore potential errors */ })
  228. this.uppy.off('file-removed', fileRemovedHandler)
  229. } else {
  230. this.client.updateNumberOfFilesInAssembly(newAssembly, nbOfRemainingFiles)
  231. .catch(() => { /* ignore potential errors */ })
  232. }
  233. }
  234. }
  235. this.uppy.on('file-removed', fileRemovedHandler)
  236. this.uppy.emit('transloadit:assembly-created', status, fileIDs)
  237. this.uppy.log(`[Transloadit] Created Assembly ${assemblyID}`)
  238. return assembly
  239. }).catch((err) => {
  240. const wrapped = new ErrorWithCause(`${this.i18n('creatingAssemblyFailed')}: ${err.message}`, { cause: err })
  241. if ('details' in err) {
  242. wrapped.details = err.details
  243. }
  244. if ('assembly' in err) {
  245. wrapped.assembly = err.assembly
  246. }
  247. throw wrapped
  248. })
  249. }
  250. #createAssemblyWatcher (assemblyID, uploadID) {
  251. // AssemblyWatcher tracks completion states of all Assemblies in this upload.
  252. const watcher = new AssemblyWatcher(this.uppy, assemblyID)
  253. watcher.on('assembly-complete', (id) => {
  254. const files = this.getAssemblyFiles(id)
  255. files.forEach((file) => {
  256. this.completedFiles[file.id] = true
  257. this.uppy.emit('postprocess-complete', file)
  258. })
  259. })
  260. watcher.on('assembly-error', (id, error) => {
  261. // Clear postprocessing state for all our files.
  262. const filesFromAssembly = this.getAssemblyFiles(id)
  263. filesFromAssembly.forEach((file) => {
  264. // TODO Maybe make a postprocess-error event here?
  265. this.uppy.emit('upload-error', file, error)
  266. this.uppy.emit('postprocess-complete', file)
  267. })
  268. // Reset `tus` key in the file state, so when the upload is retried,
  269. // old tus upload is not re-used — Assebmly expects a new upload, can't currently
  270. // re-use the old one. See: https://github.com/transloadit/uppy/issues/4412
  271. // and `onReceiveUploadUrl` in @uppy/tus
  272. const files = { ...this.uppy.getState().files }
  273. filesFromAssembly.forEach(file => delete files[file.id].tus)
  274. this.uppy.setState({ files })
  275. this.uppy.emit('error', error)
  276. })
  277. this.assemblyWatchers[uploadID] = watcher
  278. }
  279. #shouldWaitAfterUpload () {
  280. return this.opts.waitForEncoding || this.opts.waitForMetadata
  281. }
  282. /**
  283. * Used when `importFromUploadURLs` is enabled: reserves all files in
  284. * the Assembly.
  285. */
  286. #reserveFiles (assembly, fileIDs) {
  287. return Promise.all(fileIDs.map((fileID) => {
  288. const file = this.uppy.getFile(fileID)
  289. return this.client.reserveFile(assembly.status, file)
  290. }))
  291. }
  292. /**
  293. * Used when `importFromUploadURLs` is enabled: adds files to the Assembly
  294. * once they have been fully uploaded.
  295. */
  296. #onFileUploadURLAvailable = (rawFile) => {
  297. const file = this.uppy.getFile(rawFile.id)
  298. if (!file?.transloadit?.assembly) {
  299. return
  300. }
  301. const { assemblies } = this.getPluginState()
  302. const assembly = assemblies[file.transloadit.assembly]
  303. this.client.addFile(assembly, file).catch((err) => {
  304. this.uppy.log(err)
  305. this.uppy.emit('transloadit:import-error', assembly, file.id, err)
  306. })
  307. }
  308. #findFile (uploadedFile) {
  309. const files = this.uppy.getFiles()
  310. for (let i = 0; i < files.length; i++) {
  311. const file = files[i]
  312. // Completed file upload.
  313. if (file.uploadURL === uploadedFile.tus_upload_url) {
  314. return file
  315. }
  316. // In-progress file upload.
  317. if (file.tus && file.tus.uploadUrl === uploadedFile.tus_upload_url) {
  318. return file
  319. }
  320. if (!uploadedFile.is_tus_file) {
  321. // Fingers-crossed check for non-tus uploads, eg imported from S3.
  322. if (file.name === uploadedFile.name && file.size === uploadedFile.size) {
  323. return file
  324. }
  325. }
  326. }
  327. return undefined
  328. }
  329. #onFileUploadComplete (assemblyId, uploadedFile) {
  330. const state = this.getPluginState()
  331. const file = this.#findFile(uploadedFile)
  332. if (!file) {
  333. this.uppy.log('[Transloadit] Couldn’t find the file, it was likely removed in the process')
  334. return
  335. }
  336. this.setPluginState({
  337. files: {
  338. ...state.files,
  339. [uploadedFile.id]: {
  340. assembly: assemblyId,
  341. id: file.id,
  342. uploadedFile,
  343. },
  344. },
  345. })
  346. this.uppy.emit('transloadit:upload', uploadedFile, this.getAssembly(assemblyId))
  347. }
  348. /**
  349. * Callback when a new Assembly result comes in.
  350. *
  351. * @param {string} assemblyId
  352. * @param {string} stepName
  353. * @param {object} result
  354. */
  355. #onResult (assemblyId, stepName, result) {
  356. const state = this.getPluginState()
  357. const file = state.files[result.original_id]
  358. // The `file` may not exist if an import robot was used instead of a file upload.
  359. result.localId = file ? file.id : null // eslint-disable-line no-param-reassign
  360. const entry = {
  361. result,
  362. stepName,
  363. id: result.id,
  364. assembly: assemblyId,
  365. }
  366. this.setPluginState({
  367. results: [...state.results, entry],
  368. })
  369. this.uppy.emit('transloadit:result', stepName, result, this.getAssembly(assemblyId))
  370. }
  371. /**
  372. * When an Assembly has finished processing, get the final state
  373. * and emit it.
  374. *
  375. * @param {object} status
  376. */
  377. #onAssemblyFinished (status) {
  378. const url = status.assembly_ssl_url
  379. this.client.getAssemblyStatus(url).then((finalStatus) => {
  380. const assemblyId = finalStatus.assembly_id
  381. const state = this.getPluginState()
  382. this.setPluginState({
  383. assemblies: {
  384. ...state.assemblies,
  385. [assemblyId]: finalStatus,
  386. },
  387. })
  388. this.uppy.emit('transloadit:complete', finalStatus)
  389. })
  390. }
  391. async #cancelAssembly (assembly) {
  392. await this.client.cancelAssembly(assembly)
  393. // TODO bubble this through AssemblyWatcher so its event handlers can clean up correctly
  394. this.uppy.emit('transloadit:assembly-cancelled', assembly)
  395. }
  396. /**
  397. * When all files are removed, cancel in-progress Assemblies.
  398. */
  399. #onCancelAll = async ({ reason } = {}) => {
  400. try {
  401. if (reason !== 'user') return
  402. const { uploadsAssemblies } = this.getPluginState()
  403. const assemblyIDs = Object.values(uploadsAssemblies).flat(1)
  404. const assemblies = assemblyIDs.map((assemblyID) => this.getAssembly(assemblyID))
  405. await Promise.all(assemblies.map((assembly) => this.#cancelAssembly(assembly)))
  406. } catch (err) {
  407. this.uppy.log(err)
  408. }
  409. }
  410. /**
  411. * Custom state serialization for the Golden Retriever plugin.
  412. * It will pass this back to the `_onRestored` function.
  413. *
  414. * @param {Function} setData
  415. */
  416. #getPersistentData = (setData) => {
  417. const { assemblies, uploadsAssemblies } = this.getPluginState()
  418. setData({
  419. [this.id]: {
  420. assemblies,
  421. uploadsAssemblies,
  422. },
  423. })
  424. }
  425. #onRestored = (pluginData) => {
  426. const savedState = pluginData && pluginData[this.id] ? pluginData[this.id] : {}
  427. const previousAssemblies = savedState.assemblies || {}
  428. const uploadsAssemblies = savedState.uploadsAssemblies || {}
  429. if (Object.keys(uploadsAssemblies).length === 0) {
  430. // Nothing to restore.
  431. return
  432. }
  433. // Convert loaded Assembly statuses to a Transloadit plugin state object.
  434. const restoreState = (assemblies) => {
  435. const files = {}
  436. const results = []
  437. for (const [id, status] of Object.entries(assemblies)) {
  438. status.uploads.forEach((uploadedFile) => {
  439. const file = this.#findFile(uploadedFile)
  440. files[uploadedFile.id] = {
  441. id: file.id,
  442. assembly: id,
  443. uploadedFile,
  444. }
  445. })
  446. const state = this.getPluginState()
  447. Object.keys(status.results).forEach((stepName) => {
  448. for (const result of status.results[stepName]) {
  449. const file = state.files[result.original_id]
  450. result.localId = file ? file.id : null
  451. results.push({
  452. id: result.id,
  453. result,
  454. stepName,
  455. assembly: id,
  456. })
  457. }
  458. })
  459. }
  460. this.setPluginState({
  461. assemblies,
  462. files,
  463. results,
  464. uploadsAssemblies,
  465. })
  466. }
  467. // Set up the Assembly instances and AssemblyWatchers for existing Assemblies.
  468. const restoreAssemblies = () => {
  469. // eslint-disable-next-line no-shadow
  470. const { assemblies, uploadsAssemblies } = this.getPluginState()
  471. // Set up the assembly watchers again for all the ongoing uploads.
  472. Object.keys(uploadsAssemblies).forEach((uploadID) => {
  473. const assemblyIDs = uploadsAssemblies[uploadID]
  474. this.#createAssemblyWatcher(assemblyIDs, uploadID)
  475. })
  476. const allAssemblyIDs = Object.keys(assemblies)
  477. allAssemblyIDs.forEach((id) => {
  478. const assembly = new Assembly(assemblies[id], this.#rateLimitedQueue)
  479. this.#connectAssembly(assembly)
  480. })
  481. }
  482. // Force-update all Assemblies to check for missed events.
  483. const updateAssemblies = () => {
  484. const { assemblies } = this.getPluginState()
  485. return Promise.all(
  486. Object.keys(assemblies).map((id) => {
  487. return this.activeAssemblies[id].update()
  488. }),
  489. )
  490. }
  491. // Restore all Assembly state.
  492. this.restored = Promise.resolve().then(() => {
  493. restoreState(previousAssemblies)
  494. restoreAssemblies()
  495. return updateAssemblies()
  496. })
  497. this.restored.then(() => {
  498. this.restored = null
  499. })
  500. }
  501. #connectAssembly (assembly) {
  502. const { status } = assembly
  503. const id = status.assembly_id
  504. this.activeAssemblies[id] = assembly
  505. // Sync local `assemblies` state
  506. assembly.on('status', (newStatus) => {
  507. const { assemblies } = this.getPluginState()
  508. this.setPluginState({
  509. assemblies: {
  510. ...assemblies,
  511. [id]: newStatus,
  512. },
  513. })
  514. })
  515. assembly.on('upload', (file) => {
  516. this.#onFileUploadComplete(id, file)
  517. })
  518. assembly.on('error', (error) => {
  519. error.assembly = assembly.status // eslint-disable-line no-param-reassign
  520. this.uppy.emit('transloadit:assembly-error', assembly.status, error)
  521. })
  522. assembly.on('executing', () => {
  523. this.uppy.emit('transloadit:assembly-executing', assembly.status)
  524. })
  525. assembly.on('execution-progress', (details) => {
  526. this.uppy.emit('transloadit:execution-progress', details)
  527. if (details.progress_combined != null) {
  528. // TODO: Transloadit emits progress information for the entire Assembly combined
  529. // (progress_combined) and for each imported/uploaded file (progress_per_original_file).
  530. // Uppy's current design requires progress to be set for each file, which is then
  531. // averaged to get the total progress (see calculateProcessingProgress.js).
  532. // Therefore, we currently set the combined progres for every file, so that this is
  533. // the same value that is displayed to the end user, although we have more accurate
  534. // per-file progress as well. We cannot use this here or otherwise progress from
  535. // imported files would not be counted towards the total progress because imported
  536. // files are not registered with Uppy.
  537. for (const file of this.uppy.getFiles()) {
  538. this.uppy.emit('postprocess-progress', file, {
  539. mode: 'determinate',
  540. value: details.progress_combined / 100,
  541. message: this.i18n('encoding'),
  542. })
  543. }
  544. }
  545. })
  546. if (this.opts.waitForEncoding) {
  547. assembly.on('result', (stepName, result) => {
  548. this.#onResult(id, stepName, result)
  549. })
  550. }
  551. if (this.opts.waitForEncoding) {
  552. assembly.on('finished', () => {
  553. this.#onAssemblyFinished(assembly.status)
  554. })
  555. } else if (this.opts.waitForMetadata) {
  556. assembly.on('metadata', () => {
  557. this.#onAssemblyFinished(assembly.status)
  558. })
  559. }
  560. // No need to connect to the socket if the Assembly has completed by now.
  561. if (assembly.ok === 'ASSEMBLY_COMPLETE') {
  562. return assembly
  563. }
  564. assembly.connect()
  565. return assembly
  566. }
  567. #prepareUpload = (fileIDs, uploadID) => {
  568. const files = fileIDs.map(id => this.uppy.getFile(id))
  569. const filesWithoutErrors = files.filter((file) => {
  570. if (!file.error) {
  571. this.uppy.emit('preprocess-progress', file, {
  572. mode: 'indeterminate',
  573. message: this.i18n('creatingAssembly'),
  574. })
  575. return true
  576. }
  577. return false
  578. })
  579. // eslint-disable-next-line no-shadow
  580. const createAssembly = async ({ fileIDs, options }) => {
  581. try {
  582. const assembly = await this.#createAssembly(fileIDs, uploadID, options)
  583. if (this.opts.importFromUploadURLs) {
  584. await this.#reserveFiles(assembly, fileIDs)
  585. }
  586. fileIDs.forEach((fileID) => {
  587. const file = this.uppy.getFile(fileID)
  588. this.uppy.emit('preprocess-complete', file)
  589. })
  590. return assembly
  591. } catch (err) {
  592. fileIDs.forEach((fileID) => {
  593. const file = this.uppy.getFile(fileID)
  594. // Clear preprocessing state when the Assembly could not be created,
  595. // otherwise the UI gets confused about the lingering progress keys
  596. this.uppy.emit('preprocess-complete', file)
  597. this.uppy.emit('upload-error', file, err)
  598. })
  599. throw err
  600. }
  601. }
  602. const { uploadsAssemblies } = this.getPluginState()
  603. this.setPluginState({
  604. uploadsAssemblies: {
  605. ...uploadsAssemblies,
  606. [uploadID]: [],
  607. },
  608. })
  609. const assemblyOptions = new AssemblyOptions(filesWithoutErrors, this.opts)
  610. return assemblyOptions.build()
  611. .then((assemblies) => Promise.all(assemblies.map(createAssembly)))
  612. .then((maybeCreatedAssemblies) => {
  613. const createdAssemblies = maybeCreatedAssemblies.filter(Boolean)
  614. const assemblyIDs = createdAssemblies.map(assembly => assembly.status.assembly_id)
  615. this.#createAssemblyWatcher(assemblyIDs, uploadID)
  616. return Promise.all(createdAssemblies.map(assembly => this.#connectAssembly(assembly)))
  617. })
  618. // If something went wrong before any Assemblies could be created,
  619. // clear all processing state.
  620. .catch((err) => {
  621. filesWithoutErrors.forEach((file) => {
  622. this.uppy.emit('preprocess-complete', file)
  623. this.uppy.emit('upload-error', file, err)
  624. })
  625. throw err
  626. })
  627. }
  628. #afterUpload = (fileIDs, uploadID) => {
  629. const files = fileIDs.map(fileID => this.uppy.getFile(fileID))
  630. // Only use files without errors
  631. const filteredFileIDs = files.filter((file) => !file.error).map(file => file.id)
  632. const state = this.getPluginState()
  633. // If we're still restoring state, wait for that to be done.
  634. if (this.restored) {
  635. return this.restored.then(() => {
  636. return this.#afterUpload(filteredFileIDs, uploadID)
  637. })
  638. }
  639. const assemblyIDs = state.uploadsAssemblies[uploadID]
  640. const closeSocketConnections = () => {
  641. assemblyIDs.forEach((assemblyID) => {
  642. const assembly = this.activeAssemblies[assemblyID]
  643. assembly.close()
  644. delete this.activeAssemblies[assemblyID]
  645. })
  646. }
  647. // If we don't have to wait for encoding metadata or results, we can close
  648. // the socket immediately and finish the upload.
  649. if (!this.#shouldWaitAfterUpload()) {
  650. closeSocketConnections()
  651. const assemblies = assemblyIDs.map((id) => this.getAssembly(id))
  652. this.uppy.addResultData(uploadID, { transloadit: assemblies })
  653. return Promise.resolve()
  654. }
  655. // If no Assemblies were created for this upload, we also do not have to wait.
  656. // There's also no sockets or anything to close, so just return immediately.
  657. if (assemblyIDs.length === 0) {
  658. this.uppy.addResultData(uploadID, { transloadit: [] })
  659. return Promise.resolve()
  660. }
  661. const incompleteFiles = files.filter(file => !hasProperty(this.completedFiles, file.id))
  662. incompleteFiles.forEach((file) => {
  663. this.uppy.emit('postprocess-progress', file, {
  664. mode: 'indeterminate',
  665. message: this.i18n('encoding'),
  666. })
  667. })
  668. const watcher = this.assemblyWatchers[uploadID]
  669. return watcher.promise.then(() => {
  670. closeSocketConnections()
  671. const assemblies = assemblyIDs.map((id) => this.getAssembly(id))
  672. // Remove the Assembly ID list for this upload,
  673. // it's no longer going to be used anywhere.
  674. const uploadsAssemblies = { ...this.getPluginState().uploadsAssemblies }
  675. delete uploadsAssemblies[uploadID]
  676. this.setPluginState({ uploadsAssemblies })
  677. this.uppy.addResultData(uploadID, {
  678. transloadit: assemblies,
  679. })
  680. })
  681. }
  682. #closeAssemblyIfExists = (assemblyID) => {
  683. this.activeAssemblies[assemblyID]?.close()
  684. }
  685. #onError = (err = null, uploadID) => {
  686. const state = this.getPluginState()
  687. const assemblyIDs = state.uploadsAssemblies[uploadID]
  688. assemblyIDs?.forEach(this.#closeAssemblyIfExists)
  689. this.client.submitError(err)
  690. // if we can't report the error that sucks
  691. .catch(sendErrorToConsole(err))
  692. }
  693. #onTusError = (file, err) => {
  694. this.#closeAssemblyIfExists(file?.transloadit?.assembly)
  695. if (err?.message?.startsWith('tus: ')) {
  696. const endpoint = err.originalRequest?.getUnderlyingObject()?.responseURL
  697. this.client.submitError(err, { endpoint, type: 'TUS_ERROR' })
  698. // if we can't report the error that sucks
  699. .catch(sendErrorToConsole(err))
  700. }
  701. }
  702. install () {
  703. this.uppy.addPreProcessor(this.#prepareUpload)
  704. this.uppy.addPostProcessor(this.#afterUpload)
  705. // We may need to close socket.io connections on error.
  706. this.uppy.on('error', this.#onError)
  707. // Handle cancellation.
  708. this.uppy.on('cancel-all', this.#onCancelAll)
  709. // For error reporting.
  710. this.uppy.on('upload-error', this.#onTusError)
  711. if (this.opts.importFromUploadURLs) {
  712. // No uploader needed when importing; instead we take the upload URL from an existing uploader.
  713. this.uppy.on('upload-success', this.#onFileUploadURLAvailable)
  714. } else {
  715. this.uppy.use(Tus, {
  716. // Disable tus-js-client fingerprinting, otherwise uploading the same file at different times
  717. // will upload to an outdated Assembly, and we won't get socket events for it.
  718. //
  719. // To resume a Transloadit upload, we need to reconnect to the websocket, and the state that's
  720. // required to do that is not saved by tus-js-client's fingerprinting. We need the tus URL,
  721. // the Assembly URL, and the WebSocket URL, at least. We also need to know _all_ the files that
  722. // were added to the Assembly, so we can properly complete it. All that state is handled by
  723. // Golden Retriever. So, Golden Retriever is required to do resumability with the Transloadit plugin,
  724. // and we disable Tus's default resume implementation to prevent bad behaviours.
  725. storeFingerprintForResuming: false,
  726. // Only send Assembly metadata to the tus endpoint.
  727. allowedMetaFields: ['assembly_url', 'filename', 'fieldname'],
  728. // Pass the limit option to @uppy/tus
  729. limit: this.opts.limit,
  730. rateLimitedQueue: this.#rateLimitedQueue,
  731. retryDelays: this.opts.retryDelays,
  732. })
  733. }
  734. this.uppy.on('restore:get-data', this.#getPersistentData)
  735. this.uppy.on('restored', this.#onRestored)
  736. this.setPluginState({
  737. // Contains Assembly status objects, indexed by their ID.
  738. assemblies: {},
  739. // Contains arrays of Assembly IDs, indexed by the upload ID that they belong to.
  740. uploadsAssemblies: {},
  741. // Contains file data from Transloadit, indexed by their Transloadit-assigned ID.
  742. files: {},
  743. // Contains result data from Transloadit.
  744. results: [],
  745. })
  746. // We cannot cancel individual files because Assemblies tend to contain many files.
  747. const { capabilities } = this.uppy.getState()
  748. this.uppy.setState({
  749. capabilities: {
  750. ...capabilities,
  751. individualCancellation: false,
  752. },
  753. })
  754. }
  755. uninstall () {
  756. this.uppy.removePreProcessor(this.#prepareUpload)
  757. this.uppy.removePostProcessor(this.#afterUpload)
  758. this.uppy.off('error', this.#onError)
  759. if (this.opts.importFromUploadURLs) {
  760. this.uppy.off('upload-success', this.#onFileUploadURLAvailable)
  761. }
  762. const { capabilities } = this.uppy.getState()
  763. this.uppy.setState({
  764. capabilities: {
  765. ...capabilities,
  766. individualCancellation: true,
  767. },
  768. })
  769. }
  770. getAssembly (id) {
  771. const { assemblies } = this.getPluginState()
  772. return assemblies[id]
  773. }
  774. getAssemblyFiles (assemblyID) {
  775. return this.uppy.getFiles().filter((file) => {
  776. return file?.transloadit?.assembly === assemblyID
  777. })
  778. }
  779. }
  780. export {
  781. COMPANION_URL,
  782. COMPANION_ALLOWED_HOSTS,
  783. }