index.js 27 KB

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