UNPKG

@uppy/transloadit

Version:

The Transloadit plugin can be used to upload files to Transloadit for all kinds of processing, such as transcoding video, resizing images, zipping/unzipping, and more

1,007 lines (881 loc) 31.6 kB
import type { Body, DefinePluginOpts, Meta, PluginOpts, Uppy, UppyFile, } from '@uppy/core' import { BasePlugin } from '@uppy/core' import Tus, { type TusDetailedError, type TusOpts } from '@uppy/tus' import { ErrorWithCause, hasProperty, RateLimitedQueue } from '@uppy/utils' import type { AssemblyStatus, AssemblyStatusResult, AssemblyStatusUpload, CreateAssemblyParams, } from 'transloadit' import packageJson from '../package.json' with { type: 'json' } import Assembly from './Assembly.js' import AssemblyWatcher from './AssemblyWatcher.js' import Client, { type AssemblyError } from './Client.js' import locale from './locale.js' export type AssemblyResponse = AssemblyStatus export type AssemblyFile = AssemblyStatusUpload export type AssemblyResult = AssemblyStatusResult & { localId: string | null } export type AssemblyParameters = CreateAssemblyParams export interface AssemblyOptions { params?: AssemblyParameters | string | null fields?: Record<string, string | number> | string[] | null signature?: string | null } export type OptionsWithRestructuredFields = Omit<AssemblyOptions, 'fields'> & { fields: Record<string, string | number> } export interface TransloaditOptions<_M extends Meta, _B extends Body> extends PluginOpts { service?: string errorReporting?: boolean waitForEncoding?: boolean waitForMetadata?: boolean importFromUploadURLs?: boolean alwaysRunAssembly?: boolean limit?: number clientName?: string | null retryDelays?: number[] assemblyOptions?: | AssemblyOptions | (() => Promise<AssemblyOptions> | AssemblyOptions) } const defaultOptions = { service: 'https://api2.transloadit.com', errorReporting: true, waitForEncoding: false, waitForMetadata: false, alwaysRunAssembly: false, importFromUploadURLs: false, limit: 20, retryDelays: [7_000, 10_000, 15_000, 20_000], clientName: null, } satisfies TransloaditOptions<any, any> export type Opts<M extends Meta, B extends Body> = DefinePluginOpts< TransloaditOptions<M, B>, keyof typeof defaultOptions > type TransloaditState = { files: Record< string, { assembly: string; id: string; uploadedFile: AssemblyFile } > results: Array<{ result: AssemblyResult stepName: string id: string assembly: string }> } /** * State we want to store in Golden Retriever to be able to recover uploads. */ type PersistentState = { assemblyResponse: AssemblyResponse } declare module '@uppy/core' { // biome-ignore lint/correctness/noUnusedVariables: must be defined export interface UppyEventMap<M extends Meta, B extends Body> { // We're also overriding the `restored` event as it is now populated with Transloadit state. restored: (pluginData: Record<string, TransloaditState>) => void 'restore:get-data': ( setData: (arg: Record<string, PersistentState>) => void, ) => void 'transloadit:assembly-created': ( assembly: AssemblyResponse, fileIDs: string[], ) => void 'transloadit:assembly-cancel': (assembly: AssemblyResponse) => void 'transloadit:import-error': ( assembly: AssemblyResponse, fileID: string, error: Error, ) => void 'transloadit:assembly-error': ( assembly: AssemblyResponse, error: Error, ) => void 'transloadit:assembly-executing': (assembly: AssemblyResponse) => void 'transloadit:assembly-cancelled': (assembly: AssemblyResponse) => void 'transloadit:upload': ( file: AssemblyFile, assembly: AssemblyResponse, ) => void 'transloadit:result': ( stepName: string, result: AssemblyResult, assembly: AssemblyResponse, ) => void 'transloadit:complete': (assembly: AssemblyResponse) => void 'transloadit:execution-progress': (details: { progress_combined?: number }) => void } } declare module '@uppy/utils' { export interface UppyFile<M extends Meta, B extends Body> { transloadit?: { assembly: string } tus?: TusOpts<M, B> } } const sendErrorToConsole = (originalErr: Error) => (err: Error) => { const error = new ErrorWithCause('Failed to send error to the client', { cause: err, }) console.error(error, originalErr) } function validateParams(params?: AssemblyOptions['params']): void { if (params == null) { throw new Error('Transloadit: The `params` option is required.') } let parsed: AssemblyParameters if (typeof params === 'string') { try { parsed = JSON.parse(params) as AssemblyParameters } catch (err) { // Tell the user that this is not an Uppy bug! throw new ErrorWithCause( 'Transloadit: The `params` option is a malformed JSON string.', { cause: err }, ) } } else { parsed = params } if (!parsed.auth || !parsed.auth.key) { throw new Error( 'Transloadit: The `params.auth.key` option is required. ' + 'You can find your Transloadit API key at https://transloadit.com/c/template-credentials', ) } } function ensureAssemblyId(status: AssemblyResponse): string { if (!status.assembly_id) { console.warn('Assembly status is missing `assembly_id`.', status) throw new Error('Transloadit: Assembly status is missing `assembly_id`.') } return status.assembly_id } function ensureUrl( label: string, ...candidates: Array<string | undefined> ): string { for (const value of candidates) { if (typeof value === 'string' && value.length > 0) { return value } } throw new Error(`Transloadit: Assembly status is missing ${label}.`) } export function getAssemblyUrl( assembly: Pick<AssemblyResponse, 'assembly_ssl_url' | 'assembly_url'>, ): string { return ensureUrl( '`assembly_url`', assembly.assembly_url, assembly.assembly_ssl_url, ) } export function getAssemblyUrlSsl( assembly: Pick<AssemblyResponse, 'assembly_ssl_url' | 'assembly_url'>, ): string { return ensureUrl( '`assembly_ssl_url`', assembly.assembly_ssl_url, assembly.assembly_url, ) } const COMPANION_URL = 'https://api2.transloadit.com/companion' // Regex matching acceptable postMessage() origins for authentication feedback from companion. const COMPANION_ALLOWED_HOSTS = /\.transloadit\.com$/ // Regex used to check if a Companion address is run by Transloadit. const TL_COMPANION = /https?:\/\/api2(?:-\w+)?\.transloadit\.com\/companion/ /** * Upload files to Transloadit using Tus. */ export default class Transloadit< M extends Meta, B extends Body, > extends BasePlugin<Opts<M, B>, M, B, TransloaditState> { static VERSION = packageJson.version #rateLimitedQueue: RateLimitedQueue client: Client<M, B> assembly?: Assembly #watcher!: AssemblyWatcher<M, B> completedFiles: Record<string, boolean> restored: Promise<void> | null = null constructor(uppy: Uppy<M, B>, opts?: TransloaditOptions<M, B>) { super(uppy, { ...defaultOptions, ...opts }) this.type = 'uploader' this.id = this.opts.id || 'Transloadit' this.defaultLocale = locale this.#rateLimitedQueue = new RateLimitedQueue(this.opts.limit) this.i18nInit() this.client = new Client({ service: this.opts.service, client: this.#getClientVersion(), errorReporting: this.opts.errorReporting, rateLimitedQueue: this.#rateLimitedQueue, }) // Contains a file IDs that have completed postprocessing before the upload // they belong to has entered the postprocess stage. this.completedFiles = Object.create(null) } #getClientVersion() { const list = [ // @ts-expect-error VERSION comes from babel, TS does not understand `uppy-core:${this.uppy.constructor.VERSION}`, // @ts-expect-error VERSION comes from babel, TS does not understand `uppy-transloadit:${this.constructor.VERSION}`, `uppy-tus:${Tus.VERSION}`, ] const addPluginVersion = (pluginName: string, versionName: string) => { const plugin = this.uppy.getPlugin(pluginName) if (plugin) { // @ts-expect-error VERSION comes from babel, TS does not understand list.push(`${versionName}:${plugin.constructor.VERSION}`) } } if (this.opts.importFromUploadURLs) { addPluginVersion('XHRUpload', 'uppy-xhr-upload') addPluginVersion('AwsS3', 'uppy-aws-s3') addPluginVersion('AwsS3Multipart', 'uppy-aws-s3-multipart') } addPluginVersion('Dropbox', 'uppy-dropbox') addPluginVersion('Box', 'uppy-box') addPluginVersion('Facebook', 'uppy-facebook') addPluginVersion('GoogleDrive', 'uppy-google-drive') addPluginVersion('GoogleDrivePicker', 'uppy-google-drive-picker') addPluginVersion('GooglePhotosPicker', 'uppy-google-photos-picker') addPluginVersion('Instagram', 'uppy-instagram') addPluginVersion('OneDrive', 'uppy-onedrive') addPluginVersion('Zoom', 'uppy-zoom') addPluginVersion('Url', 'uppy-url') if (this.opts.clientName != null) { list.push(this.opts.clientName) } return list.join(',') } /** * Attach metadata to files to configure the Tus plugin to upload to Transloadit. * Also use Transloadit's Companion * * See: https://github.com/tus/tusd/wiki/Uploading-to-Transloadit-using-tus#uploading-using-tus */ #attachAssemblyMetadata(file: UppyFile<M, B>, status: AssemblyResponse) { // Add the metadata parameters Transloadit needs. const assemblyUrl = getAssemblyUrl(status) const tusEndpoint = ensureUrl('`tus_url`', status.tus_url) const assemblyId = ensureAssemblyId(status) const meta = { ...file.meta, // @TODO(tim-kos), can we safely bump this to assembly_ssl_url / getAssemblyUrlSsl? assembly_url: assemblyUrl, filename: file.name, fieldname: 'file', } // Add Assembly-specific Tus endpoint. const tus = { ...file.tus, endpoint: tusEndpoint, // Include X-Request-ID headers for better debugging. addRequestId: true, } // Set Companion location. We only add this, if 'file' has the attribute // remote, because this is the criteria to identify remote files. // We only replace the hostname for Transloadit's companions, so that // people can also self-host them while still using Transloadit for encoding. let { remote } = file if ( file.remote && status.companion_url && TL_COMPANION.test(file.remote.companionUrl) ) { const newHost = status.companion_url.replace(/\/$/, '') const path = file.remote.url .replace(file.remote.companionUrl, '') .replace(/^\//, '') remote = { ...file.remote, companionUrl: newHost, url: `${newHost}/${path}`, } } // Store the Assembly ID this file is in on the file under the `transloadit` key. const newFile = { ...file, transloadit: { assembly: assemblyId, }, } // Only configure the Tus plugin if we are uploading straight to Transloadit (the default). if (!this.opts.importFromUploadURLs) { Object.assign(newFile, { meta, tus, remote }) } return newFile } async #createAssembly( fileIDs: string[], assemblyOptions: OptionsWithRestructuredFields, ) { this.uppy.log('[Transloadit] Create Assembly') try { const newAssembly = await this.client.createAssembly({ ...assemblyOptions, expectedFiles: fileIDs.length, }) const files = this.uppy .getFiles() .filter(({ id }) => fileIDs.includes(id)) if (files.length === 0 && fileIDs.length !== 0) { // All files have been removed, cancelling. await this.client.cancelAssembly(newAssembly) return null } const assembly = new Assembly(newAssembly, this.#rateLimitedQueue) const { status } = assembly const assemblyID = ensureAssemblyId(status) const updatedFiles: Record<string, UppyFile<M, B>> = {} files.forEach((file) => { updatedFiles[file.id] = this.#attachAssemblyMetadata(file, status) }) this.uppy.setState({ files: { ...this.uppy.getState().files, ...updatedFiles, }, }) this.uppy.emit('transloadit:assembly-created', status, fileIDs) this.uppy.log(`[Transloadit] Created Assembly ${assemblyID}`) return assembly } catch (err) { // TODO: use AssemblyError? const wrapped = new ErrorWithCause( `${this.i18n('creatingAssemblyFailed')}: ${err.message}`, { cause: err }, ) if ('details' in err) { // @ts-expect-error details is not in the Error type wrapped.details = err.details } if ('assembly' in err) { // @ts-expect-error assembly is not in the Error type wrapped.assembly = err.assembly } throw wrapped } } #createAssemblyWatcher(idOrArrayOfIds: string | string[]) { // AssemblyWatcher tracks completion states of all Assemblies in this upload. const ids = Array.isArray(idOrArrayOfIds) ? idOrArrayOfIds : [idOrArrayOfIds] const watcher = new AssemblyWatcher(this.uppy, ids) watcher.on('assembly-complete', (id: string) => { const files = this.getAssemblyFiles(id) files.forEach((file) => { this.completedFiles[file.id] = true this.uppy.emit('postprocess-complete', file) }) }) watcher.on('assembly-error', (id: string, error: Error) => { // Clear postprocessing state for all our files. const filesFromAssembly = this.getAssemblyFiles(id) filesFromAssembly.forEach((file) => { // TODO Maybe make a postprocess-error event here? this.uppy.emit('upload-error', file, error) this.uppy.emit('postprocess-complete', file) }) // Reset `tus` key in the file state, so when the upload is retried, // old tus upload is not re-used — Assebmly expects a new upload, can't currently // re-use the old one. See: https://github.com/transloadit/uppy/issues/4412 // and `onReceiveUploadUrl` in @uppy/tus const files = { ...this.uppy.getState().files } filesFromAssembly.forEach((file) => delete files[file.id].tus) this.uppy.setState({ files }) this.uppy.emit('error', error) }) this.#watcher = watcher } #shouldWaitAfterUpload() { return this.opts.waitForEncoding || this.opts.waitForMetadata } /** * Used when `importFromUploadURLs` is enabled: reserves all files in * the Assembly. */ #reserveFiles(assembly: Assembly, fileIDs: string[]) { return Promise.all( fileIDs.map((fileID) => { const file = this.uppy.getFile(fileID) return this.client.reserveFile(assembly.status, file) }), ) } /** * Used when `importFromUploadURLs` is enabled: adds files to the Assembly * once they have been fully uploaded. */ #onFileUploadURLAvailable = (rawFile: UppyFile<M, B> | undefined) => { const file = this.uppy.getFile(rawFile!.id) if (!file?.transloadit?.assembly) { return } const { status } = this.assembly! this.client.addFile(status, file).catch((err) => { this.uppy.log(err) this.uppy.emit('transloadit:import-error', status, file.id, err) }) } #findFile(uploadedFile: AssemblyFile) { const files = this.uppy.getFiles() for (let i = 0; i < files.length; i++) { const file = files[i] // Completed file upload. if (file.uploadURL === uploadedFile.tus_upload_url) { return file } // In-progress file upload. if (file.tus && file.tus.uploadUrl === uploadedFile.tus_upload_url) { return file } if (!uploadedFile.is_tus_file) { // Fingers-crossed check for non-tus uploads, eg imported from S3. if ( file.name === uploadedFile.name && file.size === uploadedFile.size ) { return file } } } return undefined } #onFileUploadComplete(assemblyId: string, uploadedFile: AssemblyFile) { const state = this.getPluginState() const file = this.#findFile(uploadedFile) if (!file) { this.uppy.log( '[Transloadit] Couldn’t find the file, it was likely removed in the process', ) return } this.setPluginState({ files: { ...state.files, [uploadedFile.id]: { assembly: assemblyId, id: file.id, uploadedFile, }, }, }) this.uppy.emit('transloadit:upload', uploadedFile, this.getAssembly()!) } #onResult(assemblyId: string, stepName: string, result: AssemblyResult) { const state = this.getPluginState() if (!('id' in result)) { console.warn('Result has no id', result) return } if (typeof result.id !== 'string') { console.warn('Result has no id of type string', result) return } const entry = { result, stepName, id: result.id, assembly: assemblyId, } this.setPluginState({ results: [...state.results, entry], }) this.uppy.emit( 'transloadit:result', stepName, entry.result, this.getAssembly()!, ) } /** * When an Assembly has finished processing, get the final state * and emit it. */ #onAssemblyFinished(assembly: Assembly) { const url = getAssemblyUrlSsl(assembly.status) this.client.getAssemblyStatus(url).then((finalStatus) => { assembly.status = finalStatus this.uppy.emit('transloadit:complete', finalStatus) }) } async #cancelAssembly(assembly: AssemblyResponse) { await this.client.cancelAssembly(assembly) // TODO bubble this through AssemblyWatcher so its event handlers can clean up correctly this.uppy.emit('transloadit:assembly-cancelled', assembly) this.assembly = undefined } /** * When all files are removed, cancel in-progress Assemblies. */ #onCancelAll = async () => { if (!this.assembly) return try { await this.#cancelAssembly(this.assembly.status) } catch (err) { this.uppy.log(err) } } /** * Custom state serialization for the Golden Retriever plugin. * It will pass this back to the `_onRestored` function. */ #getPersistentData = ( setData: (arg: Record<string, PersistentState>) => void, ) => { if (this.assembly) { setData({ [this.id]: { assemblyResponse: this.assembly.status } }) } } #onRestored = (pluginData: Record<string, unknown>) => { const savedState = ( pluginData?.[this.id] ? pluginData[this.id] : {} ) as PersistentState const previousAssembly = savedState.assemblyResponse if (!previousAssembly) { // Nothing to restore. return } // Convert loaded Assembly statuses to a Transloadit plugin state object. const restoreState = () => { const files: Record< string, { id: string; assembly: string; uploadedFile: AssemblyFile } > = {} const results: { result: AssemblyResult stepName: string id: string assembly: string }[] = [] const id = ensureAssemblyId(previousAssembly) previousAssembly.uploads?.forEach((uploadedFile) => { const file = this.#findFile(uploadedFile) files[uploadedFile.id] = { id: file!.id, assembly: id, uploadedFile, } }) const state = this.getPluginState() const restoredResults = previousAssembly.results ?? {} Object.keys(restoredResults).forEach((stepName) => { const stepResults = restoredResults[stepName] ?? [] for (const result of stepResults) { if (!('id' in result)) { console.warn('Result has no id', result) continue } if (typeof result.id !== 'string') { console.warn('Result has no id of type string', result) continue } if (!('original_id' in result)) { console.warn('Result has no original_id', result) continue } if (typeof result.original_id !== 'string') { console.warn('Result has no original_id of type string', result) continue } const file = state.files[result.original_id] results.push({ id: result.id, result: { ...result, localId: file ? file.id : null }, stepName, assembly: id, }) } }) this.assembly = new Assembly(previousAssembly, this.#rateLimitedQueue) this.assembly.status = previousAssembly this.setPluginState({ files, results }) return files } // Set up the Assembly instances and AssemblyWatchers for existing Assemblies. const restoreAssemblies = (ids: string[]) => { this.#createAssemblyWatcher(ensureAssemblyId(previousAssembly)) this.#connectAssembly(this.assembly!, ids) } // Force-update Assembly to check for missed events. const updateAssembly = () => { return this.assembly?.update() } // Restore all Assembly state. this.restored = (async () => { const files = restoreState() restoreAssemblies(Object.keys(files)) await updateAssembly() this.restored = null })() this.restored.catch((err) => { this.uppy.log('Failed to restore', err) }) } #connectAssembly(assembly: Assembly, ids: UppyFile<M, B>['id'][]) { const { status } = assembly const id = ensureAssemblyId(status) this.assembly = assembly assembly.on('upload', (file: AssemblyFile) => { this.#onFileUploadComplete(id, file) }) assembly.on('error', (error: AssemblyError) => { error.assembly = assembly.status this.uppy.emit('transloadit:assembly-error', assembly.status, error) }) assembly.on('executing', () => { this.uppy.emit('transloadit:assembly-executing', assembly.status) }) assembly.on( 'execution-progress', (details: { progress_combined?: number }) => { this.uppy.emit('transloadit:execution-progress', details) if (details.progress_combined != null) { // TODO: Transloadit emits progress information for the entire Assembly combined // (progress_combined) and for each imported/uploaded file (progress_per_original_file). // Uppy's current design requires progress to be set for each file, which is then // averaged to get the total progress (see calculateProcessingProgress.js). // Therefore, we currently set the combined progres for every file, so that this is // the same value that is displayed to the end user, although we have more accurate // per-file progress as well. We cannot use this here or otherwise progress from // imported files would not be counted towards the total progress because imported // files are not registered with Uppy. for (const file of this.uppy.getFilesByIds(ids)) { this.uppy.emit('postprocess-progress', file, { mode: 'determinate', value: details.progress_combined / 100, message: this.i18n('encoding'), }) } } }, ) if (this.opts.waitForEncoding) { assembly.on('result', (stepName: string, result: AssemblyResult) => { this.#onResult(id, stepName, result) }) } if (this.opts.waitForEncoding) { assembly.on('finished', () => { this.#onAssemblyFinished(assembly) }) } else if (this.opts.waitForMetadata) { assembly.on('metadata', () => { this.#onAssemblyFinished(assembly) }) } // No need to connect to the socket if the Assembly has completed by now. // @ts-expect-error ok does not exist on Assembly? if (assembly.ok === 'ASSEMBLY_COMPLETE') { return assembly } assembly.connect() return assembly } #prepareUpload = async (fileIDs: string[]) => { const assemblyOptions = ( typeof this.opts.assemblyOptions === 'function' ? await this.opts.assemblyOptions() : this.opts.assemblyOptions ) as OptionsWithRestructuredFields assemblyOptions.fields ??= {} validateParams(assemblyOptions.params) try { const assembly = // this.assembly can already be defined if we recovered files with Golden Retriever (this.#onRestored) this.assembly ?? (await this.#createAssembly(fileIDs, assemblyOptions)) if (assembly == null) throw new Error('All files were canceled after assembly was created') if (this.opts.importFromUploadURLs) { await this.#reserveFiles(assembly, fileIDs) } fileIDs.forEach((fileID) => { const file = this.uppy.getFile(fileID) this.uppy.emit('preprocess-complete', file) }) this.#createAssemblyWatcher(ensureAssemblyId(assembly.status)) this.#connectAssembly(assembly, fileIDs) } catch (err) { fileIDs.forEach((fileID) => { const file = this.uppy.getFile(fileID) // Clear preprocessing state when the Assembly could not be created, // otherwise the UI gets confused about the lingering progress keys this.uppy.emit('preprocess-complete', file) this.uppy.emit('upload-error', file, err) }) throw err } } #afterUpload = async (fileIDs: string[], uploadID: string): Promise<void> => { try { // If we're still restoring state, wait for that to be done. await this.restored const files = fileIDs .map((fileID) => this.uppy.getFile(fileID)) // Only use files without errors .filter((file) => !file.error) const assemblyID = this.assembly ? ensureAssemblyId(this.assembly.status) : undefined const closeSocketConnections = () => { this.assembly?.close() } // If we don't have to wait for encoding metadata or results, we can close // the socket immediately and finish the upload. if (!this.#shouldWaitAfterUpload()) { closeSocketConnections() const status = this.assembly?.status if (status != null) { this.uppy.addResultData(uploadID, { transloadit: [status], }) } return } // If no Assemblies were created for this upload, we also do not have to wait. // There's also no sockets or anything to close, so just return immediately. if (!assemblyID) { this.uppy.addResultData(uploadID, { transloadit: [] }) return } const incompleteFiles = files.filter( (file) => !hasProperty(this.completedFiles, file.id), ) incompleteFiles.forEach((file) => { this.uppy.emit('postprocess-progress', file, { mode: 'indeterminate', message: this.i18n('encoding'), }) }) await this.#watcher.promise // assembly is now done processing! closeSocketConnections() const status = this.assembly?.status if (status != null) { this.uppy.addResultData(uploadID, { transloadit: [status], }) } } finally { // in case allowMultipleUploadBatches is true and the user wants to upload again, // we need to allow a new assembly to be created. // see https://github.com/transloadit/uppy/issues/5397 this.assembly = undefined } } #closeAssemblyIfExists = () => { this.assembly?.close() } #onError = (err: { name: string; message: string; details?: string }) => { this.#closeAssemblyIfExists() this.assembly = undefined this.client .submitError(err) // if we can't report the error that sucks .catch(sendErrorToConsole(err)) } #onTusError = (_: UppyFile<M, B> | undefined, err: Error) => { this.#closeAssemblyIfExists() if (err?.message?.startsWith('tus: ')) { const endpoint = ( err as TusDetailedError ).originalRequest?.getUnderlyingObject()?.responseURL as string this.client .submitError(err, { endpoint }) // if we can't report the error that sucks .catch(sendErrorToConsole(err)) } } install(): void { this.uppy.addPreProcessor(this.#prepareUpload) this.uppy.addPostProcessor(this.#afterUpload) // We may need to close socket.io connections on error. this.uppy.on('error', this.#onError) // Handle cancellation. this.uppy.on('cancel-all', this.#onCancelAll) this.uppy.on('upload-error', this.#onTusError) if (this.opts.importFromUploadURLs) { // No uploader needed when importing; instead we take the upload URL from an existing uploader. this.uppy.on('upload-success', this.#onFileUploadURLAvailable) } else { // we don't need it here. // the regional endpoint from the Transloadit API before we can set it. this.uppy.use(Tus, { // Disable tus-js-client fingerprinting, otherwise uploading the same file at different times // will upload to an outdated Assembly, and we won't get socket events for it. // // To resume a Transloadit upload, we need to reconnect to the websocket, and the state that's // required to do that is not saved by tus-js-client's fingerprinting. We need the tus URL, // the Assembly URL, and the WebSocket URL, at least. We also need to know _all_ the files that // were added to the Assembly, so we can properly complete it. All that state is handled by // Golden Retriever. So, Golden Retriever is required to do resumability with the Transloadit plugin, // and we disable Tus's default resume implementation to prevent bad behaviours. storeFingerprintForResuming: false, // Send all metadata to Transloadit. Metadata set by the user // ends up as in the template as `file.user_meta` allowedMetaFields: true, // Pass the limit option to @uppy/tus limit: this.opts.limit, rateLimitedQueue: this.#rateLimitedQueue, retryDelays: this.opts.retryDelays, }) } this.uppy.on('restore:get-data', this.#getPersistentData) this.uppy.on('restored', this.#onRestored) this.setPluginState({ // Contains file data from Transloadit, indexed by their Transloadit-assigned ID. files: {}, // Contains result data from Transloadit. results: [], }) // We cannot cancel individual files because Assemblies tend to contain many files. const { capabilities } = this.uppy.getState() this.uppy.setState({ capabilities: { ...capabilities, individualCancellation: false, }, }) } uninstall(): void { this.uppy.removePreProcessor(this.#prepareUpload) this.uppy.removePostProcessor(this.#afterUpload) this.uppy.off('error', this.#onError) if (this.opts.importFromUploadURLs) { this.uppy.off('upload-success', this.#onFileUploadURLAvailable) } const { capabilities } = this.uppy.getState() this.uppy.setState({ capabilities: { ...capabilities, individualCancellation: true, }, }) } getAssembly(): AssemblyResponse | undefined { return this.assembly?.status } getAssemblyFiles(assemblyID: string): UppyFile<M, B>[] { return this.uppy.getFiles().filter((file) => { return file?.transloadit?.assembly === assemblyID }) } } export { COMPANION_URL, COMPANION_ALLOWED_HOSTS }