uppy/packages/@uppy/transloadit/src/index.ts
Merlijn Vos 5684efa64e
Introduce @uppy/image-generator (#6056)
Closes #5378 

- Introduce `@uppy/image-generator`, a new plugin to generate images
based on a prompt via Transloadit
- until we have "golden templates" the idea is to just send
[steps](https://transloadit.com/docs/topics/templates/#overruling-templates-at-runtime)
- because we must send steps and since we must use signature
authentication for security, which is signed based on the params we
send, we can't reuse the `assemblyOptions` the consumers is already
passing to `@uppy/transloadit` (if they use that uploaders, not needed).
- Remove `SearchInput` (this component was trying to be too many things,
all with conditional boolean props, which is bad practise) in favor of
`useSearchForm` and reuse this hook in two new components `SearchView`
and `FilterInput`
- Reuse all the styles from `SearchProviderView`. This deviates from the
design in #5378. It felt too inconsistent to me to do another UI here
again. For the initial version, I think it's best to stay consistent and
then redesign with search providers taken into account too.
- Because the service is so slow, I went a bit further with the loading
state to show funny messages that rotate while loading mostly because
users will start thinking it is broken after 5 seconds while it fact we
are still loading. But open to ideas here.

This unfortunately means the integration for the consumer is not as lean
and pretty as you would hope. On the upside, it does give them complete
freedom.

```ts
.use(ImageGenerator, {
  assemblyOptions: async (prompt) => {
    const res = await fetch(`/assembly-options?prompt=${encodeURIComponent(prompt)}`)
    return res.json()
  }
})
```

on the consumer's server:

```ts
import crypto from 'node:crypto'

const utcDateString = (ms) => {
  return new Date(ms)
    .toISOString()
    .replace(/-/g, '/')
    .replace(/T/, ' ')
    .replace(/\.\d+Z$/, '+00:00')
}

// expire 1 hour from now (this must be milliseconds)
const expires = utcDateString(Date.now() + 1 * 60 * 60 * 1000)
const authKey = 'YOUR_TRANSLOADIT_KEY'
const authSecret = 'YOUR_TRANSLOADIT_SECRET'

const params = JSON.stringify({
  auth: {
    key: authKey,
    expires,
  },
  // can not contain any more steps, the only step must be /image/generate
  steps: {
    generated_image: { // can be named different
      robot: '/image/generate',
      result: true, // mandatory
      aspect_ratio: '2:3', // up to them
      model: 'flux-1.1-pro-ultra', // up to them
      prompt, // mandatory
      num_outputs: 2, // up to them
    },
  },
})
const signatureBytes = crypto.createHmac('sha384', authSecret).update(Buffer.from(params, 'utf-8'))
// The final signature needs the hash name in front, so
// the hashing algorithm can be updated in a backwards-compatible
// way when old algorithms become insecure.
const signature = `sha384:${signatureBytes.digest('hex')}`

// respond with { params, signature } JSON to the client
```


https://github.com/user-attachments/assets/9217e457-b38b-48ac-81f0-37a417309e98



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds AI image generation plugin using Transloadit, exports low-level
Transloadit APIs, and replaces SearchInput with new
FilterInput/SearchView + useSearchForm across provider views.
> 
> - **New plugin: `@uppy/image-generator`**
> - UI plugin to generate images from a prompt via Transloadit
(`src/index.tsx`, styles, locale, build configs).
> - Integrated into dev Dashboard and included in `uppy` bundle and
global styles.
> - **Provider Views refactor**
> - Remove `SearchInput`; introduce `useSearchForm`, `SearchView`, and
`FilterInput` components.
> - Update `ProviderView`, `SearchProviderView`, and `Webdav` to use new
components; export them from `@uppy/provider-views`.
> - **Transloadit updates**
> - Export `Assembly`, `AssemblyError`, and `Client` from
`@uppy/transloadit`.
>   - Minor internal change: normalize `assemblyOptions.fields`.
> - **Locales**
> - Add strings for image generation and minor additions (e.g.,
`chooseFiles`).
>   - Ensure locales build depends on `@uppy/image-generator`.
> - **Build config**
> - Turborepo: add `uppy#build:css` and hook `image-generator` into
locales build.
> - **Changesets**
> - `@uppy/image-generator` major; `@uppy/transloadit` minor;
`@uppy/locales` and `uppy` minor; `@uppy/provider-views` and
`@uppy/webdav` patch.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
4b1b729069. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Prakash <qxprakash@gmail.com>
2025-12-03 11:59:52 +01:00

1040 lines
32 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 {
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 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, 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
#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: 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)
}),
)
}
/**
* Allows Golden Retriever plugin to serialize the Assembly status so we can restore it later
*/
#handleAssemblyStatusUpdate = (
assemblyResponse: AssemblyResponse | undefined,
) => {
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) return
try {
await this.#cancelAssembly(this.assembly.status)
} catch (err) {
this.uppy.log(err)
}
}
#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[]) => {
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)
})
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('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 }
// 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'