uppy/packages/@uppy/transloadit/src/index.ts
Prakash 12de077e6d
remove @uppy/instagram (#6257)
closes #5455 , this PR removes all Instagram support from both the
client and Companion server.

Removed:
- Instagram from @uppy/remote-sources (src, package.json, keywords)
- Instagram export from uppy bundle (bundle.ts, index.ts, package.json)
- Companion Instagram OAuth provider (provider/index.js, grant.js)
- Companion standalone helper Instagram env vars (helper.js,
env_example)
- All Companion Instagram tests (providers.test.js,
provider-manager.test.js, fixtures/index.js, mockserver.js)
2026-05-13 14:42:10 +05:30

1090 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
AssemblyInstructionsInput,
AssemblyStatus,
AssemblyStatusResult,
AssemblyStatusUpload,
} from '@transloadit/types'
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,
type RemoteUppyFile,
} from '@uppy/utils'
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 & {
progress_combined?: number
}
export type AssemblyFile = AssemblyStatusUpload
export type AssemblyResult = AssemblyStatusResult & { localId: string | null }
export type AssemblyParameters = AssemblyInstructionsInput
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 }
>
/**
* Live status of the currently-active assembly. Tracks every status
* transition (UPLOADING → EXECUTING → ...). Cleared automatically when
* `this.assembly = undefined` (no live assembly).
*/
assemblyStatus: AssemblyResponse | undefined
/**
* Snapshot of the most recent non-null status seen. Persists across the
* gap between uploads so the UI can keep showing "your last upload's
* result" after `assemblyStatus` clears. Overwritten by the next
* status update routed through `#handleAssemblyStatusUpdate`.
*/
lastAssemblyStatus: AssemblyResponse | undefined
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, PersistentState>) => void
'restore:plugin-data-changed': (
pluginData: Record<string, PersistentState | undefined>,
) => 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
}
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Transloadit: Transloadit<M, B>
}
}
declare module '@uppy/utils' {
export interface LocalUppyFile<M extends Meta, B extends Body> {
transloadit?: { assembly: string }
tus?: TusOpts<M, B>
}
export interface RemoteUppyFile<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
static COMPANION_URL = COMPANION_URL
static COMPANION_ALLOWED_HOSTS = COMPANION_ALLOWED_HOSTS
#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('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: RemoteUppyFile<M, B>['remote'] | undefined
if ('remote' in file && file.remote) {
;({ remote } = file)
if (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)
}),
)
}
/**
* Mirrors the live Assembly status into plugin state and lets Golden
* Retriever serialize it for restore. `assemblyStatus` is written
* unconditionally — when `this.assembly = undefined`, `assemblyResponse`
* is undefined and `assemblyStatus` clears too. `lastAssemblyStatus`
* captures the most recent non-null status so the UI can keep displaying
* the previous run's result after `assemblyStatus` clears.
*/
#handleAssemblyStatusUpdate = (
assemblyResponse: AssemblyResponse | undefined,
) => {
if (assemblyResponse != null) {
this.setPluginState({ lastAssemblyStatus: assemblyResponse })
}
this.setPluginState({ assemblyStatus: assemblyResponse })
this.uppy.emit('restore:plugin-data-changed', {
[this.id]: assemblyResponse ? { assemblyResponse } : undefined,
})
}
get assembly() {
return this.#assembly
}
set assembly(newAssembly: Assembly | undefined) {
if (!newAssembly && this.assembly) {
this.assembly.off('status', this.#handleAssemblyStatusUpdate)
}
this.#assembly = newAssembly
this.#handleAssemblyStatusUpdate(newAssembly?.status)
if (newAssembly) {
newAssembly.on('status', this.#handleAssemblyStatusUpdate)
}
}
/**
* 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] Couldnt 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) {
try {
await this.#cancelAssembly(this.assembly.status)
} catch (err) {
this.uppy.log(err)
}
}
// `assemblyStatus` is cleared automatically when `this.assembly = undefined`
// (via `#cancelAssembly` above, or by `#afterUpload`'s finally block).
// Reset allowNewUpload when upload is cancelled
this.uppy.setState({ allowNewUpload: true })
}
#onRestored = (pluginData: Record<string, PersistentState>) => {
const savedState: {
assemblyResponse?: PersistentState['assemblyResponse']
} = pluginData?.[this.id] ? pluginData[this.id] : {}
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,
})
}
})
const assembly = new Assembly(previousAssembly, this.#rateLimitedQueue)
assembly.status = previousAssembly
this.assembly = assembly
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)
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[]) => {
// Prevent adding/dropping files during upload to avoid creating multiple assemblies
// TODO we should rewrite to instead infer allowNewUpload based on upload state
this.uppy.setState({ allowNewUpload: false })
const assemblyOptions = (
typeof this.opts.assemblyOptions === 'function'
? await this.opts.assemblyOptions()
: this.opts.assemblyOptions
) as OptionsWithRestructuredFields
assemblyOptions.fields = {
...(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.assembly = assembly
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)
})
// Reset allowNewUpload on error
this.uppy.setState({ allowNewUpload: true })
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
// Allow new uploads now that this upload is complete
this.uppy.setState({ allowNewUpload: true })
}
}
#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))
// Reset allowNewUpload when upload encounters an error
this.uppy.setState({ allowNewUpload: true })
}
#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('restored', this.#onRestored)
this.setPluginState({
assemblyStatus: undefined,
lastAssemblyStatus: undefined,
// 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)
this.uppy.off('cancel-all', this.#onCancelAll)
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 }
// Re-export type from @transloadit/types so callers can import it from the plugin package.
export type { AssemblyInstructionsInput } from '@transloadit/types'
// Low-level classes for advanced usage (e.g., creating assemblies without file uploads)
export { default as Assembly } from './Assembly.js'
export { AssemblyError, default as Client } from './Client.js'