index.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  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'
  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.client.cancelAssembly(newAssembly).catch(() => { /* ignore potential errors */ })
  212. this.uppy.off(fileRemovedHandler)
  213. } else if (fileRemoved.id in updatedFiles) {
  214. delete updatedFiles[fileRemoved.id]
  215. const nbOfRemainingFiles = Object.keys(updatedFiles).length
  216. if (nbOfRemainingFiles === 0) {
  217. assembly.close()
  218. this.client.cancelAssembly(newAssembly).catch(() => { /* ignore potential errors */ })
  219. this.uppy.off(fileRemovedHandler)
  220. } else {
  221. this.client.updateNumberOfFilesInAssembly(newAssembly, nbOfRemainingFiles)
  222. .catch(() => { /* ignore potential errors */ })
  223. }
  224. }
  225. }
  226. this.uppy.on('file-removed', fileRemovedHandler)
  227. this.uppy.emit('transloadit:assembly-created', status, fileIDs)
  228. this.uppy.log(`[Transloadit] Created Assembly ${assemblyID}`)
  229. return assembly
  230. }).catch((err) => {
  231. const wrapped = new ErrorWithCause(`${this.i18n('creatingAssemblyFailed')}: ${err.message}`, { cause: err })
  232. if ('details' in err) {
  233. wrapped.details = err.details
  234. }
  235. if ('assembly' in err) {
  236. wrapped.assembly = err.assembly
  237. }
  238. throw wrapped
  239. })
  240. }
  241. #createAssemblyWatcher (assemblyID, uploadID) {
  242. // AssemblyWatcher tracks completion states of all Assemblies in this upload.
  243. const watcher = new AssemblyWatcher(this.uppy, assemblyID)
  244. watcher.on('assembly-complete', (id) => {
  245. const files = this.getAssemblyFiles(id)
  246. files.forEach((file) => {
  247. this.completedFiles[file.id] = true
  248. this.uppy.emit('postprocess-complete', file)
  249. })
  250. })
  251. watcher.on('assembly-error', (id, error) => {
  252. // Clear postprocessing state for all our files.
  253. const files = this.getAssemblyFiles(id)
  254. files.forEach((file) => {
  255. // TODO Maybe make a postprocess-error event here?
  256. this.uppy.emit('upload-error', file, error)
  257. this.uppy.emit('postprocess-complete', file)
  258. })
  259. })
  260. this.assemblyWatchers[uploadID] = watcher
  261. }
  262. #shouldWaitAfterUpload () {
  263. return this.opts.waitForEncoding || this.opts.waitForMetadata
  264. }
  265. /**
  266. * Used when `importFromUploadURLs` is enabled: reserves all files in
  267. * the Assembly.
  268. */
  269. #reserveFiles (assembly, fileIDs) {
  270. return Promise.all(fileIDs.map((fileID) => {
  271. const file = this.uppy.getFile(fileID)
  272. return this.client.reserveFile(assembly.status, file)
  273. }))
  274. }
  275. /**
  276. * Used when `importFromUploadURLs` is enabled: adds files to the Assembly
  277. * once they have been fully uploaded.
  278. */
  279. #onFileUploadURLAvailable = (rawFile) => {
  280. const file = this.uppy.getFile(rawFile.id)
  281. if (!file?.transloadit?.assembly) {
  282. return
  283. }
  284. const { assemblies } = this.getPluginState()
  285. const assembly = assemblies[file.transloadit.assembly]
  286. this.client.addFile(assembly, file).catch((err) => {
  287. this.uppy.log(err)
  288. this.uppy.emit('transloadit:import-error', assembly, file.id, err)
  289. })
  290. }
  291. #findFile (uploadedFile) {
  292. const files = this.uppy.getFiles()
  293. for (let i = 0; i < files.length; i++) {
  294. const file = files[i]
  295. // Completed file upload.
  296. if (file.uploadURL === uploadedFile.tus_upload_url) {
  297. return file
  298. }
  299. // In-progress file upload.
  300. if (file.tus && file.tus.uploadUrl === uploadedFile.tus_upload_url) {
  301. return file
  302. }
  303. if (!uploadedFile.is_tus_file) {
  304. // Fingers-crossed check for non-tus uploads, eg imported from S3.
  305. if (file.name === uploadedFile.name && file.size === uploadedFile.size) {
  306. return file
  307. }
  308. }
  309. }
  310. return undefined
  311. }
  312. #onFileUploadComplete (assemblyId, uploadedFile) {
  313. const state = this.getPluginState()
  314. const file = this.#findFile(uploadedFile)
  315. if (!file) {
  316. this.uppy.log('[Transloadit] Couldn’t file the file, it was likely removed in the process')
  317. return
  318. }
  319. this.setPluginState({
  320. files: {
  321. ...state.files,
  322. [uploadedFile.id]: {
  323. assembly: assemblyId,
  324. id: file.id,
  325. uploadedFile,
  326. },
  327. },
  328. })
  329. this.uppy.emit('transloadit:upload', uploadedFile, this.getAssembly(assemblyId))
  330. }
  331. /**
  332. * Callback when a new Assembly result comes in.
  333. *
  334. * @param {string} assemblyId
  335. * @param {string} stepName
  336. * @param {object} result
  337. */
  338. #onResult (assemblyId, stepName, result) {
  339. const state = this.getPluginState()
  340. const file = state.files[result.original_id]
  341. // The `file` may not exist if an import robot was used instead of a file upload.
  342. result.localId = file ? file.id : null // eslint-disable-line no-param-reassign
  343. const entry = {
  344. result,
  345. stepName,
  346. id: result.id,
  347. assembly: assemblyId,
  348. }
  349. this.setPluginState({
  350. results: [...state.results, entry],
  351. })
  352. this.uppy.emit('transloadit:result', stepName, result, this.getAssembly(assemblyId))
  353. }
  354. /**
  355. * When an Assembly has finished processing, get the final state
  356. * and emit it.
  357. *
  358. * @param {object} status
  359. */
  360. #onAssemblyFinished (status) {
  361. const url = status.assembly_ssl_url
  362. this.client.getAssemblyStatus(url).then((finalStatus) => {
  363. const assemblyId = finalStatus.assembly_id
  364. const state = this.getPluginState()
  365. this.setPluginState({
  366. assemblies: {
  367. ...state.assemblies,
  368. [assemblyId]: finalStatus,
  369. },
  370. })
  371. this.uppy.emit('transloadit:complete', finalStatus)
  372. })
  373. }
  374. async #cancelAssembly (assembly) {
  375. await this.client.cancelAssembly(assembly)
  376. // TODO bubble this through AssemblyWatcher so its event handlers can clean up correctly
  377. this.uppy.emit('transloadit:assembly-cancelled', assembly)
  378. }
  379. /**
  380. * When all files are removed, cancel in-progress Assemblies.
  381. */
  382. #onCancelAll = async ({ reason } = {}) => {
  383. try {
  384. if (reason !== 'user') return
  385. const { uploadsAssemblies } = this.getPluginState()
  386. const assemblyIDs = Object.values(uploadsAssemblies).flat(1)
  387. const assemblies = assemblyIDs.map((assemblyID) => this.getAssembly(assemblyID))
  388. await Promise.all(assemblies.map((assembly) => this.#cancelAssembly(assembly)))
  389. } catch (err) {
  390. this.uppy.log(err)
  391. }
  392. }
  393. /**
  394. * Custom state serialization for the Golden Retriever plugin.
  395. * It will pass this back to the `_onRestored` function.
  396. *
  397. * @param {Function} setData
  398. */
  399. #getPersistentData = (setData) => {
  400. const { assemblies, uploadsAssemblies } = this.getPluginState()
  401. setData({
  402. [this.id]: {
  403. assemblies,
  404. uploadsAssemblies,
  405. },
  406. })
  407. }
  408. #onRestored = (pluginData) => {
  409. const savedState = pluginData && pluginData[this.id] ? pluginData[this.id] : {}
  410. const previousAssemblies = savedState.assemblies || {}
  411. const uploadsAssemblies = savedState.uploadsAssemblies || {}
  412. if (Object.keys(uploadsAssemblies).length === 0) {
  413. // Nothing to restore.
  414. return
  415. }
  416. // Convert loaded Assembly statuses to a Transloadit plugin state object.
  417. const restoreState = (assemblies) => {
  418. const files = {}
  419. const results = []
  420. for (const [id, status] of Object.entries(assemblies)) {
  421. status.uploads.forEach((uploadedFile) => {
  422. const file = this.#findFile(uploadedFile)
  423. files[uploadedFile.id] = {
  424. id: file.id,
  425. assembly: id,
  426. uploadedFile,
  427. }
  428. })
  429. const state = this.getPluginState()
  430. Object.keys(status.results).forEach((stepName) => {
  431. for (const result of status.results[stepName]) {
  432. const file = state.files[result.original_id]
  433. result.localId = file ? file.id : null
  434. results.push({
  435. id: result.id,
  436. result,
  437. stepName,
  438. assembly: id,
  439. })
  440. }
  441. })
  442. }
  443. this.setPluginState({
  444. assemblies,
  445. files,
  446. results,
  447. uploadsAssemblies,
  448. })
  449. }
  450. // Set up the Assembly instances and AssemblyWatchers for existing Assemblies.
  451. const restoreAssemblies = () => {
  452. // eslint-disable-next-line no-shadow
  453. const { assemblies, uploadsAssemblies } = this.getPluginState()
  454. // Set up the assembly watchers again for all the ongoing uploads.
  455. Object.keys(uploadsAssemblies).forEach((uploadID) => {
  456. const assemblyIDs = uploadsAssemblies[uploadID]
  457. this.#createAssemblyWatcher(assemblyIDs, uploadID)
  458. })
  459. const allAssemblyIDs = Object.keys(assemblies)
  460. allAssemblyIDs.forEach((id) => {
  461. const assembly = new Assembly(assemblies[id], this.#rateLimitedQueue)
  462. this.#connectAssembly(assembly)
  463. })
  464. }
  465. // Force-update all Assemblies to check for missed events.
  466. const updateAssemblies = () => {
  467. const { assemblies } = this.getPluginState()
  468. return Promise.all(
  469. Object.keys(assemblies).map((id) => {
  470. return this.activeAssemblies[id].update()
  471. }),
  472. )
  473. }
  474. // Restore all Assembly state.
  475. this.restored = Promise.resolve().then(() => {
  476. restoreState(previousAssemblies)
  477. restoreAssemblies()
  478. return updateAssemblies()
  479. })
  480. this.restored.then(() => {
  481. this.restored = null
  482. })
  483. }
  484. #connectAssembly (assembly) {
  485. const { status } = assembly
  486. const id = status.assembly_id
  487. this.activeAssemblies[id] = assembly
  488. // Sync local `assemblies` state
  489. assembly.on('status', (newStatus) => {
  490. const { assemblies } = this.getPluginState()
  491. this.setPluginState({
  492. assemblies: {
  493. ...assemblies,
  494. [id]: newStatus,
  495. },
  496. })
  497. })
  498. assembly.on('upload', (file) => {
  499. this.#onFileUploadComplete(id, file)
  500. })
  501. assembly.on('error', (error) => {
  502. error.assembly = assembly.status // eslint-disable-line no-param-reassign
  503. this.uppy.emit('transloadit:assembly-error', assembly.status, error)
  504. })
  505. assembly.on('executing', () => {
  506. this.uppy.emit('transloadit:assembly-executing', assembly.status)
  507. })
  508. if (this.opts.waitForEncoding) {
  509. assembly.on('result', (stepName, result) => {
  510. this.#onResult(id, stepName, result)
  511. })
  512. }
  513. if (this.opts.waitForEncoding) {
  514. assembly.on('finished', () => {
  515. this.#onAssemblyFinished(assembly.status)
  516. })
  517. } else if (this.opts.waitForMetadata) {
  518. assembly.on('metadata', () => {
  519. this.#onAssemblyFinished(assembly.status)
  520. })
  521. }
  522. // No need to connect to the socket if the Assembly has completed by now.
  523. if (assembly.ok === 'ASSEMBLY_COMPLETE') {
  524. return assembly
  525. }
  526. assembly.connect()
  527. return assembly
  528. }
  529. #prepareUpload = (fileIDs, uploadID) => {
  530. const files = fileIDs.map(id => this.uppy.getFile(id))
  531. const filesWithoutErrors = files.filter((file) => {
  532. if (!file.error) {
  533. this.uppy.emit('preprocess-progress', file, {
  534. mode: 'indeterminate',
  535. message: this.i18n('creatingAssembly'),
  536. })
  537. return true
  538. }
  539. return false
  540. })
  541. // eslint-disable-next-line no-shadow
  542. const createAssembly = async ({ fileIDs, options }) => {
  543. try {
  544. const assembly = await this.#createAssembly(fileIDs, uploadID, options)
  545. if (this.opts.importFromUploadURLs) {
  546. await this.#reserveFiles(assembly, fileIDs)
  547. }
  548. fileIDs.forEach((fileID) => {
  549. const file = this.uppy.getFile(fileID)
  550. this.uppy.emit('preprocess-complete', file)
  551. })
  552. return assembly
  553. } catch (err) {
  554. fileIDs.forEach((fileID) => {
  555. const file = this.uppy.getFile(fileID)
  556. // Clear preprocessing state when the Assembly could not be created,
  557. // otherwise the UI gets confused about the lingering progress keys
  558. this.uppy.emit('preprocess-complete', file)
  559. this.uppy.emit('upload-error', file, err)
  560. })
  561. throw err
  562. }
  563. }
  564. const { uploadsAssemblies } = this.getPluginState()
  565. this.setPluginState({
  566. uploadsAssemblies: {
  567. ...uploadsAssemblies,
  568. [uploadID]: [],
  569. },
  570. })
  571. const assemblyOptions = new AssemblyOptions(filesWithoutErrors, this.opts)
  572. return assemblyOptions.build()
  573. .then((assemblies) => Promise.all(assemblies.map(createAssembly)))
  574. .then((maybeCreatedAssemblies) => {
  575. const createdAssemblies = maybeCreatedAssemblies.filter(Boolean)
  576. const assemblyIDs = createdAssemblies.map(assembly => assembly.status.assembly_id)
  577. this.#createAssemblyWatcher(assemblyIDs, uploadID)
  578. return Promise.all(createdAssemblies.map(assembly => this.#connectAssembly(assembly)))
  579. })
  580. // If something went wrong before any Assemblies could be created,
  581. // clear all processing state.
  582. .catch((err) => {
  583. filesWithoutErrors.forEach((file) => {
  584. this.uppy.emit('preprocess-complete', file)
  585. this.uppy.emit('upload-error', file, err)
  586. })
  587. throw err
  588. })
  589. }
  590. #afterUpload = (fileIDs, uploadID) => {
  591. const files = fileIDs.map(fileID => this.uppy.getFile(fileID))
  592. // Only use files without errors
  593. const filteredFileIDs = files.filter((file) => !file.error).map(file => file.id)
  594. const state = this.getPluginState()
  595. // If we're still restoring state, wait for that to be done.
  596. if (this.restored) {
  597. return this.restored.then(() => {
  598. return this.#afterUpload(filteredFileIDs, uploadID)
  599. })
  600. }
  601. const assemblyIDs = state.uploadsAssemblies[uploadID]
  602. const closeSocketConnections = () => {
  603. assemblyIDs.forEach((assemblyID) => {
  604. const assembly = this.activeAssemblies[assemblyID]
  605. assembly.close()
  606. delete this.activeAssemblies[assemblyID]
  607. })
  608. }
  609. // If we don't have to wait for encoding metadata or results, we can close
  610. // the socket immediately and finish the upload.
  611. if (!this.#shouldWaitAfterUpload()) {
  612. closeSocketConnections()
  613. const assemblies = assemblyIDs.map((id) => this.getAssembly(id))
  614. this.uppy.addResultData(uploadID, { transloadit: assemblies })
  615. return Promise.resolve()
  616. }
  617. // If no Assemblies were created for this upload, we also do not have to wait.
  618. // There's also no sockets or anything to close, so just return immediately.
  619. if (assemblyIDs.length === 0) {
  620. this.uppy.addResultData(uploadID, { transloadit: [] })
  621. return Promise.resolve()
  622. }
  623. const incompleteFiles = files.filter(file => !hasProperty(this.completedFiles, file.id))
  624. incompleteFiles.forEach((file) => {
  625. this.uppy.emit('postprocess-progress', file, {
  626. mode: 'indeterminate',
  627. message: this.i18n('encoding'),
  628. })
  629. })
  630. const watcher = this.assemblyWatchers[uploadID]
  631. return watcher.promise.then(() => {
  632. closeSocketConnections()
  633. const assemblies = assemblyIDs.map((id) => this.getAssembly(id))
  634. // Remove the Assembly ID list for this upload,
  635. // it's no longer going to be used anywhere.
  636. const uploadsAssemblies = { ...this.getPluginState().uploadsAssemblies }
  637. delete uploadsAssemblies[uploadID]
  638. this.setPluginState({ uploadsAssemblies })
  639. this.uppy.addResultData(uploadID, {
  640. transloadit: assemblies,
  641. })
  642. })
  643. }
  644. #closeAssemblyIfExists = (assemblyID) => {
  645. this.activeAssemblies[assemblyID]?.close()
  646. }
  647. #onError = (err = null, uploadID) => {
  648. const state = this.getPluginState()
  649. const assemblyIDs = state.uploadsAssemblies[uploadID]
  650. assemblyIDs?.forEach(this.#closeAssemblyIfExists)
  651. this.client.submitError(err)
  652. // if we can't report the error that sucks
  653. .catch(sendErrorToConsole(err))
  654. }
  655. #onTusError = (file, err) => {
  656. this.#closeAssemblyIfExists(file?.transloadit?.assembly)
  657. if (err?.message?.startsWith('tus: ')) {
  658. const endpoint = err.originalRequest?.getUnderlyingObject()?.responseURL
  659. this.client.submitError(err, { endpoint, type: 'TUS_ERROR' })
  660. // if we can't report the error that sucks
  661. .catch(sendErrorToConsole(err))
  662. }
  663. }
  664. install () {
  665. this.uppy.addPreProcessor(this.#prepareUpload)
  666. this.uppy.addPostProcessor(this.#afterUpload)
  667. // We may need to close socket.io connections on error.
  668. this.uppy.on('error', this.#onError)
  669. // Handle cancellation.
  670. this.uppy.on('cancel-all', this.#onCancelAll)
  671. // For error reporting.
  672. this.uppy.on('upload-error', this.#onTusError)
  673. if (this.opts.importFromUploadURLs) {
  674. // No uploader needed when importing; instead we take the upload URL from an existing uploader.
  675. this.uppy.on('upload-success', this.#onFileUploadURLAvailable)
  676. } else {
  677. this.uppy.use(Tus, {
  678. // Disable tus-js-client fingerprinting, otherwise uploading the same file at different times
  679. // will upload to an outdated Assembly, and we won't get socket events for it.
  680. //
  681. // To resume a Transloadit upload, we need to reconnect to the websocket, and the state that's
  682. // required to do that is not saved by tus-js-client's fingerprinting. We need the tus URL,
  683. // the Assembly URL, and the WebSocket URL, at least. We also need to know _all_ the files that
  684. // were added to the Assembly, so we can properly complete it. All that state is handled by
  685. // Golden Retriever. So, Golden Retriever is required to do resumability with the Transloadit plugin,
  686. // and we disable Tus's default resume implementation to prevent bad behaviours.
  687. storeFingerprintForResuming: false,
  688. // Disable Companion's retry optimisation; we need to change the endpoint on retry
  689. // so it can't just reuse the same tus.Upload instance server-side.
  690. useFastRemoteRetry: false,
  691. // Only send Assembly metadata to the tus endpoint.
  692. metaFields: ['assembly_url', 'filename', 'fieldname'],
  693. // Pass the limit option to @uppy/tus
  694. limit: this.opts.limit,
  695. rateLimitedQueue: this.#rateLimitedQueue,
  696. retryDelays: this.opts.retryDelays,
  697. })
  698. }
  699. this.uppy.on('restore:get-data', this.#getPersistentData)
  700. this.uppy.on('restored', this.#onRestored)
  701. this.setPluginState({
  702. // Contains Assembly status objects, indexed by their ID.
  703. assemblies: {},
  704. // Contains arrays of Assembly IDs, indexed by the upload ID that they belong to.
  705. uploadsAssemblies: {},
  706. // Contains file data from Transloadit, indexed by their Transloadit-assigned ID.
  707. files: {},
  708. // Contains result data from Transloadit.
  709. results: [],
  710. })
  711. // We cannot cancel individual files because Assemblies tend to contain many files.
  712. const { capabilities } = this.uppy.getState()
  713. this.uppy.setState({
  714. capabilities: {
  715. ...capabilities,
  716. individualCancellation: false,
  717. },
  718. })
  719. }
  720. uninstall () {
  721. this.uppy.removePreProcessor(this.#prepareUpload)
  722. this.uppy.removePostProcessor(this.#afterUpload)
  723. this.uppy.off('error', this.#onError)
  724. if (this.opts.importFromUploadURLs) {
  725. this.uppy.off('upload-success', this.#onFileUploadURLAvailable)
  726. }
  727. const { capabilities } = this.uppy.getState()
  728. this.uppy.setState({
  729. capabilities: {
  730. ...capabilities,
  731. individualCancellation: true,
  732. },
  733. })
  734. }
  735. getAssembly (id) {
  736. const { assemblies } = this.getPluginState()
  737. return assemblies[id]
  738. }
  739. getAssemblyFiles (assemblyID) {
  740. return this.uppy.getFiles().filter((file) => {
  741. return file?.transloadit?.assembly === assemblyID
  742. })
  743. }
  744. }
  745. export {
  746. ALLOWED_COMPANION_PATTERN,
  747. COMPANION,
  748. ALLOWED_COMPANION_PATTERN as COMPANION_PATTERN,
  749. }
  750. // Backward compatibility: we want `COMPANION` and `COMPANION_PATTERN`
  751. // to keep being accessible as static properties of `Transloadit` to avoid a
  752. // breaking change.
  753. Transloadit.ALLOWED_COMPANION_PATTERN = ALLOWED_COMPANION_PATTERN // TODO: remove this line on the next major
  754. Transloadit.COMPANION = COMPANION // TODO: remove this line on the next major
  755. Transloadit.COMPANION_PATTERN = ALLOWED_COMPANION_PATTERN // TODO: remove this line on the next major