index.ts 30 KB

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