Browse Source

@uppy/transloadit: simplify plugin to always run a single assembly (#5158)

Merlijn Vos 10 months ago
parent
commit
521d90344b

+ 54 - 177
e2e/cypress/integration/dashboard-transloadit.spec.ts

@@ -1,215 +1,86 @@
+import Uppy from '@uppy/core'
+import Transloadit from '@uppy/transloadit'
+
+function getPlugin<M = any, B = any>(uppy: Uppy<M, B>) {
+  return uppy.getPlugin<Transloadit<M, B>>('Transloadit')!
+}
+
 describe('Dashboard with Transloadit', () => {
   beforeEach(() => {
     cy.visit('/dashboard-transloadit')
     cy.get('.uppy-Dashboard-input:first').as('file-input')
-    cy.intercept('/assemblies').as('createAssemblies')
-    cy.intercept('/assemblies/*').as('assemblies')
-    cy.intercept('/resumable/*').as('resumable')
   })
 
-  it('should upload cat image successfully', () => {
-    cy.get('@file-input').selectFile('cypress/fixtures/images/cat.jpg', {
-      force: true,
-    })
-
-    cy.get('.uppy-StatusBar-actionBtn--upload').click()
-    cy.wait(['@assemblies', '@resumable']).then(() => {
-      cy.get('.uppy-StatusBar-statusPrimary').should('contain', 'Complete')
-    })
-  })
+  it('should upload all files as a single assembly with UppyFile metadata in Upload-Metadata', () => {
+    cy.intercept({ path: '/assemblies', method: 'POST' }).as('createAssembly')
 
-  it('should close assembly polling when cancelled', () => {
-    cy.intercept({
-      method: 'GET',
-      url: '/assemblies/*',
-    }).as('assemblyPolling')
-    cy.intercept(
-      { method: 'DELETE', pathname: '/assemblies/*', times: 1 },
-      { statusCode: 204, body: {} },
-    ).as('delete')
+    cy.get('@file-input').selectFile(
+      [
+        'cypress/fixtures/images/cat.jpg',
+        'cypress/fixtures/images/traffic.jpg',
+      ],
+      { force: true },
+    )
 
     cy.window().then(({ uppy }) => {
-      cy.get('@file-input').selectFile(
-        [
-          'cypress/fixtures/images/cat.jpg',
-          'cypress/fixtures/images/traffic.jpg',
-          'cypress/fixtures/images/car.jpg',
-        ],
-        { force: true },
-      )
-      cy.get('.uppy-StatusBar-actionBtn--upload').click()
+      // Set metadata on all files
+      uppy.setMeta({ sharedMetaProperty: 'bar' })
+      const [file1, file2] = uppy.getFiles()
+      // Set unique metdata per file as before that's how we determined to create multiple assemblies
+      uppy.setFileMeta(file1.id, { one: 'one' })
+      uppy.setFileMeta(file2.id, { two: 'two' })
 
-      cy.wait(['@createAssemblies']).then(() => {
-        // eslint-disable-next-line
-        // @ts-ignore fix me
-        expect(
-          Object.values(uppy.getPlugin('Transloadit').activeAssemblies).every(
-            (a: any) => a.pollInterval,
-          ),
-        ).to.equal(true)
-
-        uppy.cancelAll()
+      cy.get('.uppy-StatusBar-actionBtn--upload').click()
 
-        cy.wait(['@delete']).then(() => {
-          // eslint-disable-next-line
-          // @ts-ignore fix me
-          expect(
-            Object.values(uppy.getPlugin('Transloadit').activeAssemblies).some(
-              (a: any) => a.pollInterval,
-            ),
-          ).to.equal(false)
-        })
+      cy.intercept('POST', '/resumable/*', (req) => {
+        expect(req.headers['upload-metadata']).to.include('sharedMetaProperty')
+        req.continue()
       })
-    })
-  })
-
-  // Too flaky at the moment. Arguably, this is not the right place
-  // as this is doing white box testing (testing internal state).
-  // But E2e is more about black box testing, you don’t care about the internals, only the result.
-  // May make more sense to turn this into a unit test.
-  it.skip('should emit one assembly-cancelled event when cancelled', () => {
-    const spy = cy.spy()
-
-    cy.window().then(({ uppy }) => {
-      // eslint-disable-next-line
-      // @ts-ignore fix me
-      uppy.on('transloadit:assembly-cancelled', spy)
-
-      cy.get('@file-input').selectFile(
-        [
-          'cypress/fixtures/images/cat.jpg',
-          'cypress/fixtures/images/traffic.jpg',
-        ],
-        { force: true },
-      )
 
-      cy.intercept({
-        method: 'GET',
-        url: '/assemblies/*',
-      }).as('assemblyPolling')
-      cy.intercept(
-        { method: 'PATCH', pathname: '/files/*', times: 1 },
-        { statusCode: 204, body: {} },
-      )
-      cy.intercept(
-        { method: 'DELETE', pathname: '/resumable/files/*', times: 2 },
-        { statusCode: 204, body: {} },
-      ).as('fileDeletion')
-      cy.intercept({
-        method: 'DELETE',
-        pathname: '/assemblies/*',
-        times: 1,
-      }).as('assemblyDeletion')
-
-      cy.get('.uppy-StatusBar-actionBtn--upload').click()
-      cy.wait('@assemblyPolling')
-      cy.get('button[data-cy=cancel]').click()
-      cy.wait('@assemblyDeletion')
-      // Unfortunately, waiting on a network request somehow often results in a race condition.
-      // We just want to know wether this is called or not, so waiting for 2 sec to be sure.
-      // eslint-disable-next-line cypress/no-unnecessary-waiting
-      cy.wait(2000)
-      expect(spy).to.be.calledOnce
+      cy.wait(['@createAssembly']).then(() => {
+        cy.get('.uppy-StatusBar-statusPrimary').should('contain', 'Complete')
+        // should only create one assembly
+        cy.get('@createAssembly.all').should('have.length', 1)
+      })
     })
   })
 
-  it.skip('should close assembly polling when all files are removed', () => {
-    const spy = cy.spy()
+  it('should close assembly when cancelled', () => {
+    cy.intercept({ path: '/resumable/*', method: 'POST' }).as('tusCreate')
+    cy.intercept({ path: '/assemblies', method: 'POST' }).as('createAssemblies')
+    cy.intercept({ path: '/assemblies/*', method: 'DELETE' }).as('delete')
+    cy.intercept({ path: '/resumable/files/*', method: 'DELETE' }).as(
+      'tusDelete',
+    )
 
     cy.window().then(({ uppy }) => {
-      // eslint-disable-next-line
-      // @ts-ignore fix me
-      uppy.on('transloadit:assembly-cancelled', spy)
-
       cy.get('@file-input').selectFile(
         [
           'cypress/fixtures/images/cat.jpg',
           'cypress/fixtures/images/traffic.jpg',
+          'cypress/fixtures/images/car.jpg',
         ],
         { force: true },
       )
-
-      cy.intercept({
-        method: 'GET',
-        url: '/assemblies/*',
-      }).as('assemblyPolling')
-      cy.intercept(
-        { method: 'PATCH', pathname: '/files/*', times: 1 },
-        { statusCode: 204, body: {} },
-      )
-      cy.intercept(
-        { method: 'DELETE', pathname: '/resumable/files/*', times: 2 },
-        { statusCode: 204, body: {} },
-      ).as('fileDeletion')
-      cy.intercept({
-        method: 'DELETE',
-        pathname: '/assemblies/*',
-        times: 1,
-      }).as('assemblyDeletion')
-
       cy.get('.uppy-StatusBar-actionBtn--upload').click()
-      cy.wait('@assemblyPolling')
-      // eslint-disable-next-line
-      // @ts-ignore fix me
-      expect(
-        Object.values(uppy.getPlugin('Transloadit').activeAssemblies).every(
-          (a: any) => a.pollInterval,
-        ),
-      ).to.equal(true)
 
-      const { files } = uppy.getState()
-      // eslint-disable-next-line
-      // @ts-ignore fix me
-      uppy.removeFiles(Object.keys(files))
-
-      cy.wait('@assemblyDeletion').then(() => {
-        // eslint-disable-next-line
-        // @ts-ignore fix me
-        expect(
-          Object.values(uppy.getPlugin('Transloadit').activeAssemblies).some(
-            (a: any) => a.pollInterval,
-          ),
-        ).to.equal(false)
-        expect(spy).to.be.calledOnce
-      })
-    })
-  })
+      cy.wait(['@createAssemblies', '@tusCreate']).then(() => {
+        const plugin = getPlugin(uppy)
 
-  it('should not create assembly when all individual files have been cancelled', () => {
-    cy.window().then(({ uppy }) => {
-      cy.get('@file-input').selectFile(
-        [
-          'cypress/fixtures/images/cat.jpg',
-          'cypress/fixtures/images/traffic.jpg',
-        ],
-        { force: true },
-      )
-      // eslint-disable-next-line
-      // @ts-ignore fix me
-      expect(
-        Object.values(uppy.getPlugin('Transloadit').activeAssemblies).length,
-      ).to.equal(0)
+        expect(plugin.assembly.closed).to.be.false
 
-      cy.get('.uppy-StatusBar-actionBtn--upload').click()
+        uppy.cancelAll()
 
-      const { files } = uppy.getState()
-      // eslint-disable-next-line
-      // @ts-ignore fix me
-      uppy.removeFiles(Object.keys(files))
-
-      cy.wait('@createAssemblies').then(() => {
-        // eslint-disable-next-line
-        // @ts-ignore fix me
-        expect(
-          Object.values(uppy.getPlugin('Transloadit').activeAssemblies).some(
-            (a: any) => a.pollInterval,
-          ),
-        ).to.equal(false)
+        cy.wait(['@delete', '@tusDelete']).then(() => {
+          expect(plugin.assembly.closed).to.be.true
+        })
       })
     })
   })
 
   it('should not emit error if upload is cancelled right away', () => {
+    cy.intercept({ path: '/assemblies', method: 'POST' }).as('createAssemblies')
+
     cy.get('@file-input').selectFile('cypress/fixtures/images/cat.jpg', {
       force: true,
     })
@@ -444,6 +315,9 @@ describe('Dashboard with Transloadit', () => {
   })
 
   it('should complete on retry', () => {
+    cy.intercept('/assemblies/*').as('assemblies')
+    cy.intercept('/resumable/*').as('resumable')
+
     cy.get('@file-input').selectFile(
       [
         'cypress/fixtures/images/cat.jpg',
@@ -473,6 +347,9 @@ describe('Dashboard with Transloadit', () => {
   })
 
   it('should complete when resuming after pause', () => {
+    cy.intercept({ path: '/assemblies', method: 'POST' }).as('createAssemblies')
+    cy.intercept('/resumable/*').as('resumable')
+
     cy.get('@file-input').selectFile(
       [
         'cypress/fixtures/images/cat.jpg',

+ 1 - 1
e2e/cypress/support/index.ts

@@ -19,6 +19,6 @@ import type { Uppy } from '@uppy/core'
 
 declare global {
   interface Window {
-    uppy: Uppy
+    uppy: Uppy<any, any>
   }
 }

+ 0 - 147
packages/@uppy/transloadit/src/AssemblyOptions.test.js

@@ -1,147 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import AssemblyOptions from './AssemblyOptions.ts'
-
-describe('Transloadit/AssemblyOptions', () => {
-  it('Validates response from assemblyOptions()', async () => {
-    const options = new AssemblyOptions([{ name: 'testfile' }], {
-      assemblyOptions: (file) => {
-        expect(file.name).toBe('testfile')
-        return {
-          params: '{"some":"json"}',
-        }
-      },
-    })
-
-    await expect(options.build()).rejects.toThrow(
-      /The `params\.auth\.key` option is required/,
-    )
-  })
-
-  it('Uses different assemblies for different params', async () => {
-    const data = new Uint8Array(10)
-    data.size = data.byteLength
-
-    const options = new AssemblyOptions(
-      [
-        { name: 'a.png', data },
-        { name: 'b.png', data },
-        { name: 'c.png', data },
-        { name: 'd.png', data },
-      ],
-      {
-        assemblyOptions: (file) => ({
-          params: {
-            auth: { key: 'fake key' },
-            steps: {
-              fake_step: { data: file.name },
-            },
-          },
-        }),
-      },
-    )
-
-    const assemblies = await options.build()
-    expect(assemblies).toHaveLength(4)
-    expect(assemblies[0].options.params.steps.fake_step.data).toBe('a.png')
-    expect(assemblies[1].options.params.steps.fake_step.data).toBe('b.png')
-    expect(assemblies[2].options.params.steps.fake_step.data).toBe('c.png')
-    expect(assemblies[3].options.params.steps.fake_step.data).toBe('d.png')
-  })
-
-  it('Should merge files with same parameters into one Assembly', async () => {
-    const data = new Uint8Array(10)
-    const data2 = new Uint8Array(20)
-
-    const options = new AssemblyOptions(
-      [
-        { name: 'a.png', data, size: data.byteLength },
-        { name: 'b.png', data, size: data.byteLength },
-        { name: 'c.png', data, size: data.byteLength },
-        { name: 'd.png', data: data2, size: data2.byteLength },
-      ],
-      {
-        assemblyOptions: (file) => ({
-          params: {
-            auth: { key: 'fake key' },
-            steps: {
-              fake_step: { data: file.size },
-            },
-          },
-        }),
-      },
-    )
-
-    const assemblies = await options.build()
-    expect(assemblies).toHaveLength(2)
-    expect(assemblies[0].fileIDs).toHaveLength(3)
-    expect(assemblies[1].fileIDs).toHaveLength(1)
-    expect(assemblies[0].options.params.steps.fake_step.data).toBe(10)
-    expect(assemblies[1].options.params.steps.fake_step.data).toBe(20)
-  })
-
-  it('Does not create an Assembly if no files are being uploaded', async () => {
-    const options = new AssemblyOptions([], {
-      assemblyOptions() {
-        throw new Error('should not create Assembly')
-      },
-    })
-
-    await expect(options.build()).resolves.toEqual([])
-  })
-
-  it('Creates an Assembly if no files are being uploaded but `alwaysRunAssembly` is enabled', async () => {
-    const options = new AssemblyOptions([], {
-      alwaysRunAssembly: true,
-      async assemblyOptions(file) {
-        expect(file).toBe(null)
-        return {
-          params: {
-            auth: { key: 'fake key' },
-            template_id: 'example',
-          },
-        }
-      },
-    })
-
-    await expect(options.build()).resolves.toHaveLength(1)
-  })
-
-  it('Collects metadata if `fields` is an array', async () => {
-    function defaultGetAssemblyOptions(file, options) {
-      return {
-        params: options.params,
-        signature: options.signature,
-        fields: options.fields,
-      }
-    }
-
-    const options = new AssemblyOptions(
-      [
-        {
-          id: 1,
-          meta: { watermark: 'Some text' },
-        },
-        {
-          id: 2,
-          meta: { watermark: 'ⓒ Transloadit GmbH' },
-        },
-      ],
-      {
-        fields: ['watermark'],
-        params: {
-          auth: { key: 'fake key' },
-        },
-        assemblyOptions: defaultGetAssemblyOptions,
-      },
-    )
-
-    const assemblies = await options.build()
-    expect(assemblies).toHaveLength(2)
-    expect(assemblies[0].options.fields).toMatchObject({
-      watermark: 'Some text',
-    })
-    expect(assemblies[1].options.fields).toMatchObject({
-      watermark: 'ⓒ Transloadit GmbH',
-    })
-  })
-})

+ 0 - 165
packages/@uppy/transloadit/src/AssemblyOptions.ts

@@ -1,165 +0,0 @@
-import ErrorWithCause from '@uppy/utils/lib/ErrorWithCause'
-import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
-import type {
-  AssemblyParameters,
-  Opts,
-  AssemblyOptions as Options,
-} from './index.js'
-
-/**
- * Check that Assembly parameters are present and include all required fields.
- */
-function validateParams(params?: AssemblyParameters | null): void {
-  if (params == null) {
-    throw new Error('Transloadit: The `params` option is required.')
-  }
-
-  if (typeof params === 'string') {
-    try {
-      // eslint-disable-next-line no-param-reassign
-      params = JSON.parse(params)
-    } 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 },
-      )
-    }
-  }
-
-  if (!params!.auth || !params!.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',
-    )
-  }
-}
-export type OptionsWithRestructuredFields = Omit<Options, 'fields'> & {
-  fields: Record<string, string | number>
-}
-
-/**
- * Combine Assemblies with the same options into a single Assembly for all the
- * relevant files.
- */
-function dedupe(
-  list: Array<
-    { fileIDs: string[]; options: OptionsWithRestructuredFields } | undefined
-  >,
-) {
-  const dedupeMap: Record<
-    string,
-    { fileIDArrays: string[][]; options: OptionsWithRestructuredFields }
-  > = Object.create(null)
-  for (const { fileIDs, options } of list.filter(Boolean) as Array<{
-    fileIDs: string[]
-    options: OptionsWithRestructuredFields
-  }>) {
-    const id = JSON.stringify(options)
-    if (id in dedupeMap) {
-      dedupeMap[id].fileIDArrays.push(fileIDs)
-    } else {
-      dedupeMap[id] = {
-        options,
-        fileIDArrays: [fileIDs],
-      }
-    }
-  }
-
-  return Object.values(dedupeMap).map(({ options, fileIDArrays }) => ({
-    options,
-    fileIDs: fileIDArrays.flat(1),
-  }))
-}
-
-async function getAssemblyOptions<M extends Meta, B extends Body>(
-  file: UppyFile<M, B> | null,
-  options: Opts<M, B>,
-): Promise<OptionsWithRestructuredFields> {
-  const assemblyOptions = (
-    typeof options.assemblyOptions === 'function' ?
-      await options.assemblyOptions(file, options)
-    : options.assemblyOptions) as OptionsWithRestructuredFields
-
-  validateParams(assemblyOptions.params)
-
-  const { fields } = assemblyOptions
-  if (Array.isArray(fields)) {
-    assemblyOptions.fields =
-      file == null ?
-        {}
-      : Object.fromEntries(
-          fields.map((fieldName) => [fieldName, file.meta[fieldName]]),
-        )
-  } else if (fields == null) {
-    assemblyOptions.fields = {}
-  }
-
-  return assemblyOptions
-}
-
-/**
- * Turn Transloadit plugin options and a list of files into a list of Assembly
- * options.
- */
-class AssemblyOptions<M extends Meta, B extends Body> {
-  opts: Opts<M, B>
-
-  files: UppyFile<M, B>[]
-
-  constructor(files: UppyFile<M, B>[], opts: Opts<M, B>) {
-    this.files = files
-    this.opts = opts
-  }
-
-  /**
-   * Generate a set of Assemblies that will handle the upload.
-   * Returns a Promise for an object with keys:
-   *  - fileIDs - an array of file IDs to add to this Assembly
-   *  - options - Assembly options
-   */
-  async build(): Promise<
-    { fileIDs: string[]; options: OptionsWithRestructuredFields }[]
-  > {
-    const options = this.opts
-
-    if (this.files.length > 0) {
-      return Promise.all(
-        this.files.map(async (file) => {
-          if (file == null) return undefined
-
-          const assemblyOptions = await getAssemblyOptions(file, options)
-
-          // We check if the file is present here again, because it could had been
-          // removed during the await, e.g. if the user hit cancel while we were
-          // waiting for the options.
-          if (file == null) return undefined
-
-          return {
-            fileIDs: [file.id],
-            options: assemblyOptions,
-          }
-        }),
-      ).then(dedupe)
-    }
-
-    if (options.alwaysRunAssembly) {
-      // No files, just generate one Assembly
-      const assemblyOptions = await getAssemblyOptions(null, options)
-
-      return [
-        {
-          fileIDs: [],
-          options: assemblyOptions,
-        },
-      ]
-    }
-
-    // If there are no files and we do not `alwaysRunAssembly`,
-    // don't do anything.
-    return []
-  }
-}
-
-export default AssemblyOptions
-export { validateParams }

+ 6 - 4
packages/@uppy/transloadit/src/Client.ts

@@ -4,8 +4,10 @@ import type {
 } from '@uppy/utils/lib/RateLimitedQueue'
 import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
 import fetchWithNetworkError from '@uppy/utils/lib/fetchWithNetworkError'
-import type { AssemblyResponse } from './index.js'
-import type { OptionsWithRestructuredFields } from './AssemblyOptions.js'
+import type {
+  AssemblyResponse,
+  OptionsWithRestructuredFields,
+} from './index.js'
 
 const ASSEMBLIES_ENDPOINT = '/assemblies'
 
@@ -173,9 +175,9 @@ export default class Client<M extends Meta, B extends Body> {
   /**
    * Cancel a running Assembly.
    */
-  async cancelAssembly(assembly: AssemblyResponse): Promise<AssemblyResponse> {
+  async cancelAssembly(assembly: AssemblyResponse): Promise<void> {
     const url = assembly.assembly_ssl_url
-    return this.#fetchJSON(url, {
+    await this.#fetchWithNetworkError(url, {
       method: 'DELETE',
       headers: this.#headers,
     }).catch((err) => this.#reportError(err, { url, type: 'API_ERROR' }))

+ 156 - 298
packages/@uppy/transloadit/src/index.ts

@@ -8,9 +8,6 @@ import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
 import type { Uppy } from '@uppy/core'
 import Assembly from './Assembly.ts'
 import Client, { AssemblyError } from './Client.ts'
-import AssemblyOptionsBuilder, {
-  type OptionsWithRestructuredFields,
-} from './AssemblyOptions.ts'
 import AssemblyWatcher from './AssemblyWatcher.ts'
 
 import locale from './locale.ts'
@@ -102,20 +99,6 @@ export interface AssemblyResponse {
   reason?: string
 }
 
-const sendErrorToConsole = (originalErr: Error) => (err: Error) => {
-  const error = new ErrorWithCause('Failed to send error to the client', {
-    cause: err,
-  })
-  // eslint-disable-next-line no-console
-  console.error(error, originalErr)
-}
-
-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/
-
 export interface AssemblyParameters {
   auth: {
     key: string
@@ -133,6 +116,10 @@ export interface AssemblyOptions {
   signature?: string | null
 }
 
+export type OptionsWithRestructuredFields = Omit<AssemblyOptions, 'fields'> & {
+  fields: Record<string, string | number>
+}
+
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export interface TransloaditOptions<M extends Meta, B extends Body>
   extends PluginOpts {
@@ -147,10 +134,7 @@ export interface TransloaditOptions<M extends Meta, B extends Body>
   retryDelays?: number[]
   assemblyOptions?:
     | AssemblyOptions
-    | ((
-        file?: UppyFile<M, B> | null,
-        options?: TransloaditOptions<M, B>,
-      ) => Promise<AssemblyOptions> | AssemblyOptions)
+    | (() => Promise<AssemblyOptions> | AssemblyOptions)
 }
 
 const defaultOptions = {
@@ -171,7 +155,6 @@ export type Opts<M extends Meta, B extends Body> = DefinePluginOpts<
 >
 
 type TransloaditState = {
-  assemblies: Record<string, AssemblyResponse>
   files: Record<
     string,
     { assembly: string; id: string; uploadedFile: AssemblyFile }
@@ -182,7 +165,13 @@ type TransloaditState = {
     id: string
     assembly: string
   }>
-  uploadsAssemblies: Record<string, string[]>
+}
+
+/**
+ * State we want to store in Golden Retriever to be able to recover uploads.
+ */
+type PersistentState = {
+  assemblyResponse: AssemblyResponse
 }
 
 declare module '@uppy/core' {
@@ -191,12 +180,7 @@ declare module '@uppy/core' {
     // 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,
-          Pick<TransloaditState, 'assemblies' | 'uploadsAssemblies'>
-        >,
-      ) => void,
+      setData: (arg: Record<string, PersistentState>) => void,
     ) => void
     'transloadit:assembly-created': (
       assembly: AssemblyResponse,
@@ -238,6 +222,46 @@ declare module '@uppy/utils/lib/UppyFile' {
   }
 }
 
+const sendErrorToConsole = (originalErr: Error) => (err: Error) => {
+  const error = new ErrorWithCause('Failed to send error to the client', {
+    cause: err,
+  })
+  // eslint-disable-next-line no-console
+  console.error(error, originalErr)
+}
+
+function validateParams(params?: AssemblyParameters | null): void {
+  if (params == null) {
+    throw new Error('Transloadit: The `params` option is required.')
+  }
+
+  if (typeof params === 'string') {
+    try {
+      // eslint-disable-next-line no-param-reassign
+      params = JSON.parse(params)
+    } 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 },
+      )
+    }
+  }
+
+  if (!params!.auth || !params!.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',
+    )
+  }
+}
+
+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.
  */
@@ -251,9 +275,9 @@ export default class Transloadit<
 
   client: Client<M, B>
 
-  activeAssemblies: Record<string, Assembly>
+  assembly: Assembly | null
 
-  assemblyWatchers: Record<string, AssemblyWatcher<M, B>>
+  #watcher: AssemblyWatcher<M, B>
 
   completedFiles: Record<string, boolean>
 
@@ -276,10 +300,6 @@ export default class Transloadit<
       errorReporting: this.opts.errorReporting,
       rateLimitedQueue: this.#rateLimitedQueue,
     })
-    // Contains Assembly instances for in-progress Assemblies.
-    this.activeAssemblies = {}
-    // Contains a mapping of uploadID to AssemblyWatcher
-    this.assemblyWatchers = {}
     // Contains a file IDs that have completed postprocessing before the upload
     // they belong to has entered the postprocess stage.
     this.completedFiles = Object.create(null)
@@ -382,7 +402,6 @@ export default class Transloadit<
 
   #createAssembly(
     fileIDs: string[],
-    uploadID: string,
     assemblyOptions: OptionsWithRestructuredFields,
   ) {
     this.uppy.log('[Transloadit] Create Assembly')
@@ -406,20 +425,6 @@ export default class Transloadit<
         const { status } = assembly
         const assemblyID = status.assembly_id
 
-        const { assemblies, uploadsAssemblies } = this.getPluginState()
-        this.setPluginState({
-          // Store the Assembly status.
-          assemblies: {
-            ...assemblies,
-            [assemblyID]: status,
-          },
-          // Store the list of Assemblies related to this upload.
-          uploadsAssemblies: {
-            ...uploadsAssemblies,
-            [uploadID]: [...uploadsAssemblies[uploadID], assemblyID],
-          },
-        })
-
         const updatedFiles: Record<string, UppyFile<M, B>> = {}
         files.forEach((file) => {
           updatedFiles[file.id] = this.#attachAssemblyMetadata(file, status)
@@ -432,18 +437,6 @@ export default class Transloadit<
           },
         })
 
-        // TODO: this should not live inside a `file-removed` event but somewhere more deterministic.
-        // Such as inside the function where the assembly has succeeded or cancelled.
-        // For the use case of cancelling the assembly when needed, we should try to do that with just `cancel-all`.
-        const fileRemovedHandler = () => {
-          // If the assembly has successfully completed, we do not need these checks.
-          // Otherwise we may cancel an assembly after it already succeeded
-          if (assembly.status?.ok === 'ASSEMBLY_COMPLETED') {
-            this.uppy.off('file-removed', fileRemovedHandler)
-          }
-        }
-        this.uppy.on('file-removed', fileRemovedHandler)
-
         this.uppy.emit('transloadit:assembly-created', status, fileIDs)
 
         this.uppy.log(`[Transloadit] Created Assembly ${assemblyID}`)
@@ -467,7 +460,7 @@ export default class Transloadit<
       })
   }
 
-  #createAssemblyWatcher(idOrArrayOfIds: string | string[], uploadID: string) {
+  #createAssemblyWatcher(idOrArrayOfIds: string | string[]) {
     // AssemblyWatcher tracks completion states of all Assemblies in this upload.
     const ids =
       Array.isArray(idOrArrayOfIds) ? idOrArrayOfIds : [idOrArrayOfIds]
@@ -502,7 +495,7 @@ export default class Transloadit<
       this.uppy.emit('error', error)
     })
 
-    this.assemblyWatchers[uploadID] = watcher
+    this.#watcher = watcher
   }
 
   #shouldWaitAfterUpload() {
@@ -532,12 +525,11 @@ export default class Transloadit<
       return
     }
 
-    const { assemblies } = this.getPluginState()
-    const assembly = assemblies[file.transloadit.assembly]
+    const { status } = this.assembly!
 
-    this.client.addFile(assembly, file).catch((err) => {
+    this.client.addFile(status, file).catch((err) => {
       this.uppy.log(err)
-      this.uppy.emit('transloadit:import-error', assembly, file.id, err)
+      this.uppy.emit('transloadit:import-error', status, file.id, err)
     })
   }
 
@@ -585,11 +577,7 @@ export default class Transloadit<
         },
       },
     })
-    this.uppy.emit(
-      'transloadit:upload',
-      uploadedFile,
-      this.getAssembly(assemblyId),
-    )
+    this.uppy.emit('transloadit:upload', uploadedFile, this.getAssembly()!)
   }
 
   #onResult(assemblyId: string, stepName: string, result: AssemblyResult) {
@@ -608,12 +596,7 @@ export default class Transloadit<
     this.setPluginState({
       results: [...state.results, entry],
     })
-    this.uppy.emit(
-      'transloadit:result',
-      stepName,
-      result,
-      this.getAssembly(assemblyId),
-    )
+    this.uppy.emit('transloadit:result', stepName, result, this.getAssembly()!)
   }
 
   /**
@@ -623,14 +606,7 @@ export default class Transloadit<
   #onAssemblyFinished(status: AssemblyResponse) {
     const url = status.assembly_ssl_url
     this.client.getAssemblyStatus(url).then((finalStatus) => {
-      const assemblyId = finalStatus.assembly_id
-      const state = this.getPluginState()
-      this.setPluginState({
-        assemblies: {
-          ...state.assemblies,
-          [assemblyId]: finalStatus,
-        },
-      })
+      this.assembly!.status = finalStatus
       this.uppy.emit('transloadit:complete', finalStatus)
     })
   }
@@ -646,15 +622,7 @@ export default class Transloadit<
    */
   #onCancelAll = async () => {
     try {
-      const { uploadsAssemblies } = this.getPluginState()
-      const assemblyIDs = Object.values(uploadsAssemblies).flat(1)
-      const assemblies = assemblyIDs.map((assemblyID) =>
-        this.getAssembly(assemblyID),
-      )
-
-      await Promise.all(
-        assemblies.map((assembly) => this.#cancelAssembly(assembly)),
-      )
+      await this.#cancelAssembly(this.assembly!.status)
     } catch (err) {
       this.uppy.log(err)
     }
@@ -665,37 +633,27 @@ export default class Transloadit<
    * It will pass this back to the `_onRestored` function.
    */
   #getPersistentData = (
-    setData: (
-      arg: Record<
-        string,
-        Pick<TransloaditState, 'assemblies' | 'uploadsAssemblies'>
-      >,
-    ) => void,
+    setData: (arg: Record<string, PersistentState>) => void,
   ) => {
-    const { assemblies, uploadsAssemblies } = this.getPluginState()
-
-    setData({
-      [this.id]: {
-        assemblies,
-        uploadsAssemblies,
-      },
-    })
+    if (this.assembly) {
+      setData({ [this.id]: { assemblyResponse: this.assembly.status } })
+    }
   }
 
-  #onRestored = (pluginData: Record<string, TransloaditState>) => {
-    const savedState =
-      pluginData && pluginData[this.id] ? pluginData[this.id] : {}
-    const previousAssemblies = (savedState as TransloaditState).assemblies || {}
-    const uploadsAssemblies =
-      (savedState as TransloaditState).uploadsAssemblies || {}
+  #onRestored = (pluginData: Record<string, unknown>) => {
+    const savedState = (
+      pluginData && pluginData[this.id] ?
+        pluginData[this.id]
+      : {}) as PersistentState
+    const previousAssembly = savedState.assemblyResponse
 
-    if (Object.keys(uploadsAssemblies).length === 0) {
+    if (!previousAssembly) {
       // Nothing to restore.
       return
     }
 
     // Convert loaded Assembly statuses to a Transloadit plugin state object.
-    const restoreState = (assemblies: TransloaditState['assemblies']) => {
+    const restoreState = () => {
       const files: Record<
         string,
         { id: string; assembly: string; uploadedFile: AssemblyFile }
@@ -706,70 +664,50 @@ export default class Transloadit<
         id: string
         assembly: string
       }[] = []
-      for (const [id, status] of Object.entries(assemblies)) {
-        status.uploads.forEach((uploadedFile) => {
-          const file = this.#findFile(uploadedFile)
-          files[uploadedFile.id] = {
-            id: file!.id,
-            assembly: id,
-            uploadedFile,
-          }
-        })
+      const { assembly_id: id } = previousAssembly
 
-        const state = this.getPluginState()
-        Object.keys(status.results).forEach((stepName) => {
-          for (const result of status.results[stepName]) {
-            const file = state.files[result.original_id]
-            result.localId = file ? file.id : null
-            results.push({
-              id: result.id,
-              result,
-              stepName,
-              assembly: id,
-            })
-          }
-        })
-      }
+      previousAssembly.uploads.forEach((uploadedFile) => {
+        const file = this.#findFile(uploadedFile)
+        files[uploadedFile.id] = {
+          id: file!.id,
+          assembly: id,
+          uploadedFile,
+        }
+      })
 
-      this.setPluginState({
-        assemblies,
-        files,
-        results,
-        uploadsAssemblies,
+      const state = this.getPluginState()
+      Object.keys(previousAssembly.results).forEach((stepName) => {
+        for (const result of previousAssembly.results[stepName]) {
+          const file = state.files[result.original_id]
+          result.localId = file ? file.id : null
+          results.push({
+            id: result.id,
+            result,
+            stepName,
+            assembly: id,
+          })
+        }
       })
+
+      this.assembly = new Assembly(previousAssembly, this.#rateLimitedQueue)
+      this.assembly.status = previousAssembly
+      this.setPluginState({ files, results })
     }
 
     // Set up the Assembly instances and AssemblyWatchers for existing Assemblies.
     const restoreAssemblies = () => {
-      // eslint-disable-next-line no-shadow
-      const { assemblies, uploadsAssemblies } = this.getPluginState()
-
-      // Set up the assembly watchers again for all the ongoing uploads.
-      Object.keys(uploadsAssemblies).forEach((uploadID) => {
-        const assemblyIDs = uploadsAssemblies[uploadID]
-        this.#createAssemblyWatcher(assemblyIDs, uploadID)
-      })
-
-      const allAssemblyIDs = Object.keys(assemblies)
-      allAssemblyIDs.forEach((id) => {
-        const assembly = new Assembly(assemblies[id], this.#rateLimitedQueue)
-        this.#connectAssembly(assembly)
-      })
+      this.#createAssemblyWatcher(previousAssembly.assembly_id)
+      this.#connectAssembly(this.assembly!)
     }
 
     // Force-update all Assemblies to check for missed events.
     const updateAssemblies = () => {
-      const { assemblies } = this.getPluginState()
-      return Promise.all(
-        Object.keys(assemblies).map((id) => {
-          return this.activeAssemblies[id].update()
-        }),
-      )
+      return this.assembly!.update()
     }
 
     // Restore all Assembly state.
     this.restored = Promise.resolve().then(() => {
-      restoreState(previousAssemblies)
+      restoreState()
       restoreAssemblies()
       updateAssemblies()
     })
@@ -782,18 +720,7 @@ export default class Transloadit<
   #connectAssembly(assembly: Assembly) {
     const { status } = assembly
     const id = status.assembly_id
-    this.activeAssemblies[id] = assembly
-
-    // Sync local `assemblies` state
-    assembly.on('status', (newStatus: AssemblyResponse) => {
-      const { assemblies } = this.getPluginState()
-      this.setPluginState({
-        assemblies: {
-          ...assemblies,
-          [id]: newStatus,
-        },
-      })
-    })
+    this.assembly = assembly
 
     assembly.on('upload', (file: AssemblyFile) => {
       this.#onFileUploadComplete(id, file)
@@ -859,85 +786,39 @@ export default class Transloadit<
     return assembly
   }
 
-  #prepareUpload = async (fileIDs: string[], uploadID: string) => {
-    const files = fileIDs.map((id) => this.uppy.getFile(id))
-    const filesWithoutErrors = files.filter((file) => {
-      if (!file.error) {
-        this.uppy.emit('preprocess-progress', file, {
-          mode: 'indeterminate',
-          message: this.i18n('creatingAssembly'),
-        })
-        return true
-      }
-      return false
-    })
+  #prepareUpload = async (fileIDs: string[]) => {
+    const assemblyOptions = (
+      typeof this.opts.assemblyOptions === 'function' ?
+        await this.opts.assemblyOptions()
+      : this.opts.assemblyOptions) as OptionsWithRestructuredFields
 
-    const createAssembly = async ({
-      // eslint-disable-next-line no-shadow
-      fileIDs,
-      options,
-    }: {
-      fileIDs: string[]
-      options: OptionsWithRestructuredFields
-    }) => {
-      try {
-        const assembly = (await this.#createAssembly(
-          fileIDs,
-          uploadID,
-          options,
-        )) as Assembly
-        if (this.opts.importFromUploadURLs) {
-          await this.#reserveFiles(assembly, fileIDs)
-        }
-        fileIDs.forEach((fileID) => {
-          const file = this.uppy.getFile(fileID)
-          this.uppy.emit('preprocess-complete', file)
-        })
-        return assembly
-      } 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
-      }
-    }
+    assemblyOptions.fields ??= {}
+    validateParams(assemblyOptions.params)
 
-    const { uploadsAssemblies } = this.getPluginState()
-    this.setPluginState({
-      uploadsAssemblies: {
-        ...uploadsAssemblies,
-        [uploadID]: [],
-      },
-    })
-
-    const builder = new AssemblyOptionsBuilder(filesWithoutErrors, this.opts)
-
-    await builder
-      .build()
-      .then((assemblies) => Promise.all(assemblies.map(createAssembly)))
-      .then((maybeCreatedAssemblies) => {
-        const createdAssemblies = maybeCreatedAssemblies.filter(Boolean)
-        const assemblyIDs = createdAssemblies.map(
-          (assembly) => assembly.status.assembly_id,
-        )
-        this.#createAssemblyWatcher(assemblyIDs, uploadID)
-        return Promise.all(
-          createdAssemblies.map((assembly) => this.#connectAssembly(assembly)),
-        )
+    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))) as Assembly
+      if (this.opts.importFromUploadURLs) {
+        await this.#reserveFiles(assembly, fileIDs)
+      }
+      fileIDs.forEach((fileID) => {
+        const file = this.uppy.getFile(fileID)
+        this.uppy.emit('preprocess-complete', file)
       })
-      // If something went wrong before any Assemblies could be created,
-      // clear all processing state.
-      .catch((err) => {
-        filesWithoutErrors.forEach((file) => {
-          this.uppy.emit('preprocess-complete', file)
-          this.uppy.emit('upload-error', file, err)
-        })
-        throw err
+      this.#createAssemblyWatcher(assembly.status.assembly_id)
+      this.#connectAssembly(assembly)
+    } 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 = (fileIDs: string[], uploadID: string): Promise<void> => {
@@ -947,8 +828,6 @@ export default class Transloadit<
       .filter((file) => !file.error)
       .map((file) => file.id)
 
-    const state = this.getPluginState()
-
     // If we're still restoring state, wait for that to be done.
     if (this.restored) {
       return this.restored.then(() => {
@@ -956,28 +835,25 @@ export default class Transloadit<
       })
     }
 
-    const assemblyIDs = state.uploadsAssemblies[uploadID]
+    const assemblyID = this.assembly!.status.assembly_id
 
     const closeSocketConnections = () => {
-      assemblyIDs.forEach((assemblyID) => {
-        const assembly = this.activeAssemblies[assemblyID]
-        assembly.close()
-        delete this.activeAssemblies[assemblyID]
-      })
+      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 assemblies = assemblyIDs.map((id) => this.getAssembly(id))
-      this.uppy.addResultData(uploadID, { transloadit: assemblies })
+      this.uppy.addResultData(uploadID, {
+        transloadit: [this.assembly!.status],
+      })
       return Promise.resolve()
     }
 
     // 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 (assemblyIDs.length === 0) {
+    if (!assemblyID) {
       this.uppy.addResultData(uploadID, { transloadit: [] })
       return Promise.resolve()
     }
@@ -992,36 +868,21 @@ export default class Transloadit<
       })
     })
 
-    const watcher = this.assemblyWatchers[uploadID]
-    return watcher.promise.then(() => {
+    return this.#watcher.promise.then(() => {
       closeSocketConnections()
-
-      const assemblies = assemblyIDs.map((id) => this.getAssembly(id))
-
-      // Remove the Assembly ID list for this upload,
-      // it's no longer going to be used anywhere.
-      const uploadsAssemblies = { ...this.getPluginState().uploadsAssemblies }
-      delete uploadsAssemblies[uploadID]
-      this.setPluginState({ uploadsAssemblies })
-
       this.uppy.addResultData(uploadID, {
-        transloadit: assemblies,
+        transloadit: [this.assembly!.status],
       })
     })
   }
 
-  #closeAssemblyIfExists = (assemblyID?: string) => {
-    if (!assemblyID) return
-    this.activeAssemblies[assemblyID]?.close()
+  #closeAssemblyIfExists = () => {
+    this.assembly?.close()
   }
 
   #onError = (err: { name: string; message: string; details?: string }) => {
-    // TODO: uploadID is not accessible here. The state in core has many upload IDs,
-    // so we don't know which one to get. This code never worked and no one complained.
-    // See if we run into problems with this.
-    // const state = this.getPluginState()
-    // const assemblyIDs = state.uploadsAssemblies[uploadID]
-    // assemblyIDs?.forEach(this.#closeAssemblyIfExists)
+    this.#closeAssemblyIfExists()
+    this.assembly = null
 
     this.client
       .submitError(err)
@@ -1029,8 +890,8 @@ export default class Transloadit<
       .catch(sendErrorToConsole(err))
   }
 
-  #onTusError = (file: UppyFile<M, B> | undefined, err: Error) => {
-    this.#closeAssemblyIfExists(file?.transloadit?.assembly)
+  #onTusError = (_: UppyFile<M, B> | undefined, err: Error) => {
+    this.#closeAssemblyIfExists()
     if (err?.message?.startsWith('tus: ')) {
       const endpoint = (
         err as TusDetailedError
@@ -1058,8 +919,9 @@ export default class Transloadit<
       // No uploader needed when importing; instead we take the upload URL from an existing uploader.
       this.uppy.on('upload-success', this.#onFileUploadURLAvailable)
     } else {
-      // @ts-expect-error endpoint has to be required for @uppy/tus but for some reason
       // we don't need it here.
+      // @ts-expect-error `endpoint` is required but we first have to fetch
+      // 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.
@@ -1071,8 +933,9 @@ export default class Transloadit<
         // 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,
-        // Only send Assembly metadata to the tus endpoint.
-        allowedMetaFields: ['assembly_url', 'filename', 'fieldname'],
+        // 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,
@@ -1084,10 +947,6 @@ export default class Transloadit<
     this.uppy.on('restored', this.#onRestored)
 
     this.setPluginState({
-      // Contains Assembly status objects, indexed by their ID.
-      assemblies: {},
-      // Contains arrays of Assembly IDs, indexed by the upload ID that they belong to.
-      uploadsAssemblies: {},
       // Contains file data from Transloadit, indexed by their Transloadit-assigned ID.
       files: {},
       // Contains result data from Transloadit.
@@ -1122,9 +981,8 @@ export default class Transloadit<
     })
   }
 
-  getAssembly(id: string): AssemblyResponse {
-    const { assemblies } = this.getPluginState()
-    return assemblies[id]
+  getAssembly(): AssemblyResponse | undefined {
+    return this.assembly!.status
   }
 
   getAssemblyFiles(assemblyID: string): UppyFile<M, B>[] {