瀏覽代碼

@uppy/companion-client: migrate to TS (#4864)

Merlijn Vos 1 年之前
父節點
當前提交
a0050bd79f

+ 3 - 0
packages/@uppy/companion-client/package.json

@@ -28,5 +28,8 @@
   },
   "devDependencies": {
     "vitest": "^1.2.1"
+  },
+  "peerDependencies": {
+    "@uppy/core": "workspace:^"
   }
 }

+ 2 - 0
packages/@uppy/companion-client/src/AuthError.js → packages/@uppy/companion-client/src/AuthError.ts

@@ -1,6 +1,8 @@
 'use strict'
 
 class AuthError extends Error {
+  isAuthError: boolean
+
   constructor() {
     super('Authorization required')
     this.name = 'AuthError'

+ 200 - 63
packages/@uppy/companion-client/src/Provider.js → packages/@uppy/companion-client/src/Provider.ts

@@ -1,11 +1,38 @@
-'use strict'
+import type { Uppy, BasePlugin } from '@uppy/core'
+import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import RequestClient, {
+  authErrorStatusCode,
+  type RequestOptions,
+} from './RequestClient.ts'
+import * as tokenStorage from './tokenStorage.ts'
+
+// TODO: remove deprecated options in next major release
+export type Opts = {
+  /** @deprecated */
+  serverUrl?: string
+  /** @deprecated */
+  serverPattern?: string
+  companionUrl: string
+  companionAllowedHosts?: string | RegExp | Array<string | RegExp>
+  storage?: typeof tokenStorage
+  pluginId: string
+  name?: string
+  supportsRefreshToken?: boolean
+  provider: string
+}
 
-import RequestClient, { authErrorStatusCode } from './RequestClient.js'
-import * as tokenStorage from './tokenStorage.js'
+interface ProviderPlugin<M extends Meta, B extends Body>
+  extends BasePlugin<Opts, M, B> {
+  files: UppyFile<M, B>[]
 
+  storage: typeof tokenStorage
+}
 
-const getName = (id) => {
-  return id.split('-').map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(' ')
+const getName = (id: string) => {
+  return id
+    .split('-')
+    .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
+    .join(' ')
 }
 
 function getOrigin() {
@@ -13,25 +40,51 @@ function getOrigin() {
   return location.origin
 }
 
-function getRegex(value) {
+function getRegex(value?: string | RegExp) {
   if (typeof value === 'string') {
     return new RegExp(`^${value}$`)
-  } if (value instanceof RegExp) {
+  }
+  if (value instanceof RegExp) {
     return value
   }
   return undefined
 }
 
-function isOriginAllowed(origin, allowedOrigin) {
-  const patterns = Array.isArray(allowedOrigin) ? allowedOrigin.map(getRegex) : [getRegex(allowedOrigin)]
-  return patterns
-    .some((pattern) => pattern?.test(origin) || pattern?.test(`${origin}/`)) // allowing for trailing '/'
+function isOriginAllowed(
+  origin: string,
+  allowedOrigin: string | RegExp | Array<string | RegExp> | undefined,
+) {
+  const patterns = Array.isArray(allowedOrigin)
+    ? allowedOrigin.map(getRegex)
+    : [getRegex(allowedOrigin)]
+  return patterns.some(
+    (pattern) => pattern?.test(origin) || pattern?.test(`${origin}/`),
+  ) // allowing for trailing '/'
 }
 
-export default class Provider extends RequestClient {
-  #refreshingTokenPromise
+export default class Provider<
+  M extends Meta,
+  B extends Body,
+> extends RequestClient<M, B> {
+  #refreshingTokenPromise: Promise<void> | undefined
+
+  provider: string
+
+  id: string
+
+  name: string
+
+  pluginId: string
+
+  tokenKey: string
 
-  constructor(uppy, opts) {
+  companionKeysParams?: Record<string, string>
+
+  preAuthToken: string | null
+
+  supportsRefreshToken: boolean
+
+  constructor(uppy: Uppy<M, B>, opts: Opts) {
     super(uppy, opts)
     this.provider = opts.provider
     this.id = this.provider
@@ -43,9 +96,12 @@ export default class Provider extends RequestClient {
     this.supportsRefreshToken = opts.supportsRefreshToken ?? true // todo false in next major
   }
 
-  async headers() {
-    const [headers, token] = await Promise.all([super.headers(), this.#getAuthToken()])
-    const authHeaders = {}
+  async headers(): Promise<Record<string, string>> {
+    const [headers, token] = await Promise.all([
+      super.headers(),
+      this.#getAuthToken(),
+    ])
+    const authHeaders: Record<string, string> = {}
     if (token) {
       authHeaders['uppy-auth-token'] = token
     }
@@ -58,48 +114,63 @@ export default class Provider extends RequestClient {
     return { ...headers, ...authHeaders }
   }
 
-  onReceiveResponse(response) {
+  onReceiveResponse(response: Response): Response {
     super.onReceiveResponse(response)
-    const plugin = this.uppy.getPlugin(this.pluginId)
+    const plugin = this.#getPlugin()
     const oldAuthenticated = plugin.getPluginState().authenticated
-    const authenticated = oldAuthenticated ? response.status !== authErrorStatusCode : response.status < 400
+    const authenticated = oldAuthenticated
+      ? response.status !== authErrorStatusCode
+      : response.status < 400
     plugin.setPluginState({ authenticated })
     return response
   }
 
-  async setAuthToken(token) {
-    return this.uppy.getPlugin(this.pluginId).storage.setItem(this.tokenKey, token)
+  async setAuthToken(token: string): Promise<void> {
+    return this.#getPlugin().storage.setItem(this.tokenKey, token)
   }
 
-  async #getAuthToken() {
-    return this.uppy.getPlugin(this.pluginId).storage.getItem(this.tokenKey)
+  async #getAuthToken(): Promise<string | null> {
+    return this.#getPlugin().storage.getItem(this.tokenKey)
   }
 
-  /** @protected */
-  async removeAuthToken() {
-    return this.uppy.getPlugin(this.pluginId).storage.removeItem(this.tokenKey)
+  protected async removeAuthToken(): Promise<void> {
+    return this.#getPlugin().storage.removeItem(this.tokenKey)
+  }
+
+  #getPlugin() {
+    const plugin = this.uppy.getPlugin(this.pluginId) as ProviderPlugin<M, B>
+    if (plugin == null) throw new Error('Plugin was nullish')
+    return plugin
   }
 
   /**
    * Ensure we have a preauth token if necessary. Attempts to fetch one if we don't,
    * or rejects if loading one fails.
    */
-  async ensurePreAuth() {
+  async ensurePreAuth(): Promise<void> {
     if (this.companionKeysParams && !this.preAuthToken) {
       await this.fetchPreAuthToken()
 
       if (!this.preAuthToken) {
-        throw new Error('Could not load authentication data required for third-party login. Please try again later.')
+        throw new Error(
+          'Could not load authentication data required for third-party login. Please try again later.',
+        )
       }
     }
   }
 
-  // eslint-disable-next-line class-methods-use-this
-  authQuery() {
+  // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars
+  authQuery(data: unknown): Record<string, string> {
     return {}
   }
 
-  authUrl({ authFormData, query } = {}) {
+  authUrl({
+    authFormData,
+    query,
+  }: {
+    authFormData: unknown
+    query: Record<string, string>
+  }): string {
     const params = new URLSearchParams({
       ...query,
       state: btoa(JSON.stringify({ origin: getOrigin() })),
@@ -113,14 +184,33 @@ export default class Provider extends RequestClient {
     return `${this.hostname}/${this.id}/connect?${params}`
   }
 
-  /** @protected */
-  async loginSimpleAuth({ uppyVersions, authFormData, signal }) {
-    const response = await this.post(`${this.id}/simple-auth`, { form: authFormData }, { qs: { uppyVersions }, signal })
+  protected async loginSimpleAuth({
+    uppyVersions,
+    authFormData,
+    signal,
+  }: {
+    uppyVersions: string
+    authFormData: unknown
+    signal: AbortSignal
+  }): Promise<void> {
+    type Res = { uppyAuthToken: string }
+    const response = await this.post<Res>(
+      `${this.id}/simple-auth`,
+      { form: authFormData },
+      { qs: { uppyVersions }, signal },
+    )
     this.setAuthToken(response.uppyAuthToken)
   }
 
-  /** @protected */
-  async loginOAuth({ uppyVersions, authFormData, signal }) {
+  protected async loginOAuth({
+    uppyVersions,
+    authFormData,
+    signal,
+  }: {
+    uppyVersions: string
+    authFormData: unknown
+    signal: AbortSignal
+  }): Promise<void> {
     await this.ensurePreAuth()
 
     signal.throwIfAborted()
@@ -129,9 +219,9 @@ export default class Provider extends RequestClient {
       const link = this.authUrl({ query: { uppyVersions }, authFormData })
       const authWindow = window.open(link, '_blank')
 
-      let cleanup
+      let cleanup: () => void
 
-      const handleToken = (e) => {
+      const handleToken = (e: MessageEvent<any>) => {
         if (e.source !== authWindow) {
           let jsonData = ''
           try {
@@ -143,13 +233,20 @@ export default class Provider extends RequestClient {
           } catch (err) {
             // in case JSON.stringify fails (ignored)
           }
-          this.uppy.log(`ignoring event from unknown source ${jsonData}`, 'warning')
+          this.uppy.log(
+            `ignoring event from unknown source ${jsonData}`,
+            'warning',
+          )
           return
         }
 
-        const { companionAllowedHosts } = this.uppy.getPlugin(this.pluginId).opts
+        const { companionAllowedHosts } = this.#getPlugin().opts
         if (!isOriginAllowed(e.origin, companionAllowedHosts)) {
-          reject(new Error(`rejecting event from ${e.origin} vs allowed pattern ${companionAllowedHosts}`))
+          reject(
+            new Error(
+              `rejecting event from ${e.origin} vs allowed pattern ${companionAllowedHosts}`,
+            ),
+          )
           return
         }
 
@@ -175,7 +272,7 @@ export default class Provider extends RequestClient {
       }
 
       cleanup = () => {
-        authWindow.close()
+        authWindow?.close()
         window.removeEventListener('message', handleToken)
         signal.removeEventListener('abort', cleanup)
       }
@@ -185,20 +282,29 @@ export default class Provider extends RequestClient {
     })
   }
 
-  async login({ uppyVersions, authFormData, signal }) {
+  async login({
+    uppyVersions,
+    authFormData,
+    signal,
+  }: {
+    uppyVersions: string
+    authFormData: unknown
+    signal: AbortSignal
+  }): Promise<void> {
     return this.loginOAuth({ uppyVersions, authFormData, signal })
   }
 
-  refreshTokenUrl() {
+  refreshTokenUrl(): string {
     return `${this.hostname}/${this.id}/refresh-token`
   }
 
-  fileUrl(id) {
+  fileUrl(id: string): string {
     return `${this.hostname}/${this.id}/get/${id}`
   }
 
-  /** @protected */
-  async request(...args) {
+  protected async request<ResBody extends Record<string, unknown>>(
+    ...args: Parameters<RequestClient<M, B>['request']>
+  ): Promise<ResBody> {
     await this.#refreshingTokenPromise
 
     try {
@@ -208,7 +314,7 @@ export default class Provider extends RequestClient {
       // While uploading, go to your google account settings,
       // "Third-party apps & services", then click "Companion" and "Remove access".
 
-      return await super.request(...args)
+      return await super.request<ResBody>(...args)
     } catch (err) {
       if (!this.supportsRefreshToken) throw err
       // only handle auth errors (401 from provider), and only handle them if we have a (refresh) token
@@ -220,8 +326,14 @@ export default class Provider extends RequestClient {
         // Once a refresh token operation has started, we need all other request to wait for this operation (atomically)
         this.#refreshingTokenPromise = (async () => {
           try {
-            this.uppy.log(`[CompanionClient] Refreshing expired auth token`, 'info')
-            const response = await super.request({ path: this.refreshTokenUrl(), method: 'POST' })
+            this.uppy.log(
+              `[CompanionClient] Refreshing expired auth token`,
+              'info',
+            )
+            const response = await super.request<{ uppyAuthToken: string }>({
+              path: this.refreshTokenUrl(),
+              method: 'POST',
+            })
             await this.setAuthToken(response.uppyAuthToken)
           } catch (refreshTokenErr) {
             if (refreshTokenErr.isAuthError) {
@@ -242,30 +354,44 @@ export default class Provider extends RequestClient {
     }
   }
 
-  async fetchPreAuthToken() {
+  async fetchPreAuthToken(): Promise<void> {
     if (!this.companionKeysParams) {
       return
     }
 
     try {
-      const res = await this.post(`${this.id}/preauth/`, { params: this.companionKeysParams })
+      const res = await this.post<{ token: string }>(`${this.id}/preauth/`, {
+        params: this.companionKeysParams,
+      })
       this.preAuthToken = res.token
     } catch (err) {
-      this.uppy.log(`[CompanionClient] unable to fetch preAuthToken ${err}`, 'warning')
+      this.uppy.log(
+        `[CompanionClient] unable to fetch preAuthToken ${err}`,
+        'warning',
+      )
     }
   }
 
-  list(directory, options) {
-    return this.get(`${this.id}/list/${directory || ''}`, options)
+  list<ResBody extends Record<string, unknown>>(
+    directory: string | undefined,
+    options: RequestOptions,
+  ): Promise<ResBody> {
+    return this.get<ResBody>(`${this.id}/list/${directory || ''}`, options)
   }
 
-  async logout(options) {
-    const response = await this.get(`${this.id}/logout`, options)
+  async logout<ResBody extends Record<string, unknown>>(
+    options: RequestOptions,
+  ): Promise<ResBody> {
+    const response = await this.get<ResBody>(`${this.id}/logout`, options)
     await this.removeAuthToken()
     return response
   }
 
-  static initPlugin(plugin, opts, defaultOpts) {
+  static initPlugin(
+    plugin: ProviderPlugin<any, any>, // any because static methods cannot use class generics
+    opts: Opts,
+    defaultOpts: Record<string, unknown>,
+  ): void {
     /* eslint-disable no-param-reassign */
     plugin.type = 'acquirer'
     plugin.files = []
@@ -274,19 +400,30 @@ export default class Provider extends RequestClient {
     }
 
     if (opts.serverUrl || opts.serverPattern) {
-      throw new Error('`serverUrl` and `serverPattern` have been renamed to `companionUrl` and `companionAllowedHosts` respectively in the 0.30.5 release. Please consult the docs (for example, https://uppy.io/docs/instagram/ for the Instagram plugin) and use the updated options.`')
+      throw new Error(
+        '`serverUrl` and `serverPattern` have been renamed to `companionUrl` and `companionAllowedHosts` respectively in the 0.30.5 release. Please consult the docs (for example, https://uppy.io/docs/instagram/ for the Instagram plugin) and use the updated options.`',
+      )
     }
 
     if (opts.companionAllowedHosts) {
       const pattern = opts.companionAllowedHosts
       // validate companionAllowedHosts param
-      if (typeof pattern !== 'string' && !Array.isArray(pattern) && !(pattern instanceof RegExp)) {
-        throw new TypeError(`${plugin.id}: the option "companionAllowedHosts" must be one of string, Array, RegExp`)
+      if (
+        typeof pattern !== 'string' &&
+        !Array.isArray(pattern) &&
+        !(pattern instanceof RegExp)
+      ) {
+        throw new TypeError(
+          `${plugin.id}: the option "companionAllowedHosts" must be one of string, Array, RegExp`,
+        )
       }
       plugin.opts.companionAllowedHosts = pattern
     } else if (/^(?!https?:\/\/).*$/i.test(opts.companionUrl)) {
       // does not start with https://
-      plugin.opts.companionAllowedHosts = `https://${opts.companionUrl?.replace(/^\/\//, '')}`
+      plugin.opts.companionAllowedHosts = `https://${opts.companionUrl?.replace(
+        /^\/\//,
+        '',
+      )}`
     } else {
       plugin.opts.companionAllowedHosts = new URL(opts.companionUrl).origin
     }

+ 0 - 461
packages/@uppy/companion-client/src/RequestClient.js

@@ -1,461 +0,0 @@
-'use strict'
-
-import UserFacingApiError from '@uppy/utils/lib/UserFacingApiError'
-// eslint-disable-next-line import/no-extraneous-dependencies
-import pRetry, { AbortError } from 'p-retry'
-
-import fetchWithNetworkError from '@uppy/utils/lib/fetchWithNetworkError'
-import ErrorWithCause from '@uppy/utils/lib/ErrorWithCause'
-import emitSocketProgress from '@uppy/utils/lib/emitSocketProgress'
-import getSocketHost from '@uppy/utils/lib/getSocketHost'
-
-import AuthError from './AuthError.js'
-
-import packageJson from '../package.json'
-
-// Remove the trailing slash so we can always safely append /xyz.
-function stripSlash(url) {
-  return url.replace(/\/$/, '')
-}
-
-const retryCount = 10 // set to a low number, like 2 to test manual user retries
-const socketActivityTimeoutMs = 5 * 60 * 1000 // set to a low number like 10000 to test this
-
-export const authErrorStatusCode = 401
-
-class HttpError extends Error {
-  statusCode
-
-  constructor({ statusCode, message }) {
-    super(message)
-    this.name = 'HttpError'
-    this.statusCode = statusCode
-  }
-}
-
-async function handleJSONResponse(res) {
-  if (res.status === authErrorStatusCode) {
-    throw new AuthError()
-  }
-
-  if (res.ok) {
-    return res.json()
-  }
-
-  let errMsg = `Failed request with status: ${res.status}. ${res.statusText}`
-  let errData
-  try {
-    errData = await res.json()
-
-    if (errData.message) errMsg = `${errMsg} message: ${errData.message}`
-    if (errData.requestId) errMsg = `${errMsg} request-Id: ${errData.requestId}`
-  } catch (cause) {
-    // if the response contains invalid JSON, let's ignore the error data
-    throw new Error(errMsg, { cause })
-  }
-
-  if (res.status >= 400 && res.status <= 499 && errData.message) {
-    throw new UserFacingApiError(errData.message)
-  }
-
-  throw new HttpError({ statusCode: res.status, message: errMsg })
-}
-
-export default class RequestClient {
-  static VERSION = packageJson.version
-
-  #companionHeaders
-
-  constructor(uppy, opts) {
-    this.uppy = uppy
-    this.opts = opts
-    this.onReceiveResponse = this.onReceiveResponse.bind(this)
-    this.#companionHeaders = opts?.companionHeaders
-  }
-
-  setCompanionHeaders(headers) {
-    this.#companionHeaders = headers
-  }
-
-  [Symbol.for('uppy test: getCompanionHeaders')]() {
-    return this.#companionHeaders
-  }
-
-  get hostname() {
-    const { companion } = this.uppy.getState()
-    const host = this.opts.companionUrl
-    return stripSlash(companion && companion[host] ? companion[host] : host)
-  }
-
-  async headers (emptyBody = false) {
-    const defaultHeaders = {
-      Accept: 'application/json',
-      ...(emptyBody ? undefined : {
-        // Passing those headers on requests with no data forces browsers to first make a preflight request.
-        'Content-Type': 'application/json',
-      }),
-    }
-
-    return {
-      ...defaultHeaders,
-      ...this.#companionHeaders,
-    }
-  }
-
-  onReceiveResponse({ headers }) {
-    const state = this.uppy.getState()
-    const companion = state.companion || {}
-    const host = this.opts.companionUrl
-
-    // Store the self-identified domain name for the Companion instance we just hit.
-    if (headers.has('i-am') && headers.get('i-am') !== companion[host]) {
-      this.uppy.setState({
-        companion: { ...companion, [host]: headers.get('i-am') },
-      })
-    }
-  }
-
-  #getUrl(url) {
-    if (/^(https?:|)\/\//.test(url)) {
-      return url
-    }
-    return `${this.hostname}/${url}`
-  }
-
-  /** @protected */
-  async request({ path, method = 'GET', data, skipPostResponse, signal }) {
-    try {
-      const headers = await this.headers(!data)
-      const response = await fetchWithNetworkError(this.#getUrl(path), {
-        method,
-        signal,
-        headers,
-        credentials: this.opts.companionCookiesRule || 'same-origin',
-        body: data ? JSON.stringify(data) : null,
-      })
-      if (!skipPostResponse) this.onReceiveResponse(response)
-
-      return await handleJSONResponse(response)
-    } catch (err) {
-      // pass these through
-      if (err.isAuthError || err.name === 'UserFacingApiError' || err.name === 'AbortError') throw err
-
-      throw new ErrorWithCause(`Could not ${method} ${this.#getUrl(path)}`, {
-        cause: err,
-      })
-    }
-  }
-
-  async get(path, options = undefined) {
-    // TODO: remove boolean support for options that was added for backward compatibility.
-    // eslint-disable-next-line no-param-reassign
-    if (typeof options === 'boolean') options = { skipPostResponse: options }
-    return this.request({ ...options, path })
-  }
-
-  async post(path, data, options = undefined) {
-    // TODO: remove boolean support for options that was added for backward compatibility.
-    // eslint-disable-next-line no-param-reassign
-    if (typeof options === 'boolean') options = { skipPostResponse: options }
-    return this.request({ ...options, path, method: 'POST', data })
-  }
-
-  async delete(path, data = undefined, options) {
-    // TODO: remove boolean support for options that was added for backward compatibility.
-    // eslint-disable-next-line no-param-reassign
-    if (typeof options === 'boolean') options = { skipPostResponse: options }
-    return this.request({ ...options, path, method: 'DELETE', data })
-  }
-
-  /**
-   * Remote uploading consists of two steps:
-   * 1. #requestSocketToken which starts the download/upload in companion and returns a unique token for the upload.
-   * Then companion will halt the upload until:
-   * 2. #awaitRemoteFileUpload is called, which will open/ensure a websocket connection towards companion, with the
-   * previously generated token provided. It returns a promise that will resolve/reject once the file has finished
-   * uploading or is otherwise done (failed, canceled)
-   * 
-   * @param {*} file 
-   * @param {*} reqBody 
-   * @param {*} options 
-   * @returns 
-   */
-  async uploadRemoteFile(file, reqBody, options = {}) {
-    try {
-      const { signal, getQueue } = options
-
-      return await pRetry(async () => {
-        // if we already have a serverToken, assume that we are resuming the existing server upload id
-        const existingServerToken = this.uppy.getFile(file.id)?.serverToken;
-        if (existingServerToken != null) {
-          this.uppy.log(`Connecting to exiting websocket ${existingServerToken}`)
-          return this.#awaitRemoteFileUpload({ file, queue: getQueue(), signal })
-        }
-
-        const queueRequestSocketToken = getQueue().wrapPromiseFunction(async (...args) => {
-          try {
-            return await this.#requestSocketToken(...args)
-          } catch (outerErr) {
-            // throwing AbortError will cause p-retry to stop retrying
-            if (outerErr.isAuthError) throw new AbortError(outerErr)
-
-            if (outerErr.cause == null) throw outerErr
-            const err = outerErr.cause
-
-            const isRetryableHttpError = () => (
-              [408, 409, 429, 418, 423].includes(err.statusCode)
-              || (err.statusCode >= 500 && err.statusCode <= 599 && ![501, 505].includes(err.statusCode))
-            )
-            if (err.name === 'HttpError' && !isRetryableHttpError()) throw new AbortError(err);
-
-            // p-retry will retry most other errors,
-            // but it will not retry TypeError (except network error TypeErrors)
-            throw err
-          }
-        }, { priority: -1 })
-
-        const serverToken = await queueRequestSocketToken({ file, postBody: reqBody, signal }).abortOn(signal)
-
-        if (!this.uppy.getFile(file.id)) return undefined // has file since been removed?
-
-        this.uppy.setFileState(file.id, { serverToken })
-
-        return this.#awaitRemoteFileUpload({
-          file: this.uppy.getFile(file.id), // re-fetching file because it might have changed in the meantime
-          queue: getQueue(),
-          signal
-        })
-      }, { retries: retryCount, signal, onFailedAttempt: (err) => this.uppy.log(`Retrying upload due to: ${err.message}`, 'warning') });
-    } catch (err) {
-      // this is a bit confusing, but note that an error with the `name` prop set to 'AbortError' (from AbortController)
-      // is not the same as `p-retry` `AbortError`
-      if (err.name === 'AbortError') {
-        // The file upload was aborted, it’s not an error
-        return undefined
-      }
-
-      this.uppy.emit('upload-error', file, err)
-      throw err
-    }
-  }
-
-  #requestSocketToken = async ({ file, postBody, signal }) => {
-    if (file.remote.url == null) {
-      throw new Error('Cannot connect to an undefined URL')
-    }
-
-    const res = await this.post(file.remote.url, {
-      ...file.remote.body,
-      ...postBody,
-    }, signal)
-
-    return res.token
-  }
-
-  /**
-   * This method will ensure a websocket for the specified file and returns a promise that resolves
-   * when the file has finished downloading, or rejects if it fails.
-   * It will retry if the websocket gets disconnected
-   * 
-   * @param {{ file: UppyFile, queue: RateLimitedQueue, signal: AbortSignal }} file
-   */
-  async #awaitRemoteFileUpload({ file, queue, signal }) {
-    let removeEventHandlers
-
-    const { capabilities } = this.uppy.getState()
-
-    try {
-      return await new Promise((resolve, reject) => {
-        const token = file.serverToken
-        const host = getSocketHost(file.remote.companionUrl)
-
-        /** @type {WebSocket} */
-        let socket
-        /** @type {AbortController?} */
-        let socketAbortController
-        let activityTimeout
-
-        let { isPaused } = file
-
-        const socketSend = (action, payload) => {
-          if (socket == null || socket.readyState !== socket.OPEN) {
-            this.uppy.log(`Cannot send "${action}" to socket ${file.id} because the socket state was ${String(socket?.readyState)}`, 'warning')
-            return
-          }
-
-          socket.send(JSON.stringify({
-            action,
-            payload: payload ?? {},
-          }))
-        };
-
-        function sendState() {
-          if (!capabilities.resumableUploads) return;
-
-          if (isPaused) socketSend('pause')
-          else socketSend('resume')
-        }
-
-        const createWebsocket = async () => {
-          if (socketAbortController) socketAbortController.abort()
-          socketAbortController = new AbortController()
-
-          const onFatalError = (err) => {
-            // Remove the serverToken so that a new one will be created for the retry.
-            this.uppy.setFileState(file.id, { serverToken: null })
-            socketAbortController?.abort?.()
-            reject(err)
-          }
-
-          // todo instead implement the ability for users to cancel / retry *currently uploading files* in the UI
-          function resetActivityTimeout() {
-            clearTimeout(activityTimeout)
-            if (isPaused) return
-            activityTimeout = setTimeout(() => onFatalError(new Error('Timeout waiting for message from Companion socket')), socketActivityTimeoutMs)
-          }
-
-          try {
-            await queue.wrapPromiseFunction(async () => {
-              // eslint-disable-next-line promise/param-names
-              const reconnectWebsocket = async () => new Promise((resolveSocket, rejectSocket) => {
-                socket = new WebSocket(`${host}/api/${token}`)
-
-                resetActivityTimeout()
-
-                socket.addEventListener('close', () => {
-                  socket = undefined
-                  rejectSocket(new Error('Socket closed unexpectedly'))
-                })
-
-                socket.addEventListener('error', (error) => {
-                  this.uppy.log(`Companion socket error ${JSON.stringify(error)}, closing socket`, 'warning')
-                  socket.close() // will 'close' event to be emitted
-                })
-
-                socket.addEventListener('open', () => {
-                  sendState()
-                })
-
-                socket.addEventListener('message', (e) => {
-                  resetActivityTimeout()
-
-                  try {
-                    const { action, payload } = JSON.parse(e.data)
-
-                    switch (action) {
-                      case 'progress': {
-                        emitSocketProgress(this, payload, file)
-                        break;
-                      }
-                      case 'success': {
-                        this.uppy.emit('upload-success', file, { uploadURL: payload.url })
-                        socketAbortController?.abort?.()
-                        resolve()
-                        break;
-                      }
-                      case 'error': {
-                        const { message } = payload.error
-                        throw Object.assign(new Error(message), { cause: payload.error })
-                      }
-                      default:
-                        this.uppy.log(`Companion socket unknown action ${action}`, 'warning')
-                    }
-                  } catch (err) {
-                    onFatalError(err)
-                  }
-                })
-
-                const closeSocket = () => {
-                  this.uppy.log(`Closing socket ${file.id}`, 'info')
-                  clearTimeout(activityTimeout)
-                  if (socket) socket.close()
-                  socket = undefined
-                }
-
-                socketAbortController.signal.addEventListener('abort', () => {
-                  closeSocket()
-                })
-              })
-
-              await pRetry(reconnectWebsocket, {
-                retries: retryCount,
-                signal: socketAbortController.signal,
-                onFailedAttempt: () => {
-                  if (socketAbortController.signal.aborted) return // don't log in this case
-                  this.uppy.log(`Retrying websocket ${file.id}`, 'info')
-                },
-              });
-            })().abortOn(socketAbortController.signal);
-          } catch (err) {
-            if (socketAbortController.signal.aborted) return
-            onFatalError(err)
-          }
-        }
-
-        const pause = (newPausedState) => {
-          if (!capabilities.resumableUploads) return;
-
-          isPaused = newPausedState
-          if (socket) sendState()
-
-          if (newPausedState) {
-            // Remove this file from the queue so another file can start in its place.
-            socketAbortController?.abort?.() // close socket to free up the request for other uploads
-          } else {
-            // Resuming an upload should be queued, else you could pause and then
-            // resume a queued upload to make it skip the queue.
-            createWebsocket()
-          }
-        }
-
-        const onFileRemove = (targetFile) => {
-          if (!capabilities.individualCancellation) return
-          if (targetFile.id !== file.id) return
-          socketSend('cancel')
-          socketAbortController?.abort?.()
-          this.uppy.log(`upload ${file.id} was removed`, 'info')
-          resolve()
-        }
-
-        const onCancelAll = ({ reason }) => {
-          if (reason === 'user') {
-            socketSend('cancel')
-          }
-          socketAbortController?.abort?.()
-          this.uppy.log(`upload ${file.id} was canceled`, 'info')
-          resolve()
-        };
-
-        const onFilePausedChange = (targetFileId, newPausedState) => {
-          if (targetFileId !== file.id) return
-          pause(newPausedState)
-        }
-
-        const onPauseAll = () => pause(true)
-        const onResumeAll = () => pause(false)
-
-        this.uppy.on('file-removed', onFileRemove)
-        this.uppy.on('cancel-all', onCancelAll)
-        this.uppy.on('upload-pause', onFilePausedChange)
-        this.uppy.on('pause-all', onPauseAll)
-        this.uppy.on('resume-all', onResumeAll)
-
-        removeEventHandlers = () => {
-          this.uppy.off('file-removed', onFileRemove)
-          this.uppy.off('cancel-all', onCancelAll)
-          this.uppy.off('upload-pause', onFilePausedChange)
-          this.uppy.off('pause-all', onPauseAll)
-          this.uppy.off('resume-all', onResumeAll)
-        }
-
-        signal.addEventListener('abort', () => {
-          socketAbortController?.abort();
-        })
-
-        createWebsocket()
-      })
-    } finally {
-      removeEventHandlers?.()
-    }
-  }
-}

+ 0 - 13
packages/@uppy/companion-client/src/RequestClient.test.js

@@ -1,13 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import RequestClient from './RequestClient.js'
-
-describe('RequestClient', () => {
-  it('has a hostname without trailing slash', () => {
-    const mockCore = { getState: () => ({}) }
-    const a = new RequestClient(mockCore, { companionUrl: 'http://companion.uppy.io' })
-    const b = new RequestClient(mockCore, { companionUrl: 'http://companion.uppy.io/' })
-
-    expect(a.hostname).toBe('http://companion.uppy.io')
-    expect(b.hostname).toBe('http://companion.uppy.io')
-  })
-})

+ 21 - 0
packages/@uppy/companion-client/src/RequestClient.test.ts

@@ -0,0 +1,21 @@
+import { describe, it, expect } from 'vitest'
+import RequestClient from './RequestClient.ts'
+
+describe('RequestClient', () => {
+  it('has a hostname without trailing slash', () => {
+    const mockCore = { getState: () => ({}) } as any
+    const a = new RequestClient(mockCore, {
+      pluginId: 'test',
+      provider: 'test',
+      companionUrl: 'http://companion.uppy.io',
+    })
+    const b = new RequestClient(mockCore, {
+      pluginId: 'test2',
+      provider: 'test2',
+      companionUrl: 'http://companion.uppy.io/',
+    })
+
+    expect(a.hostname).toBe('http://companion.uppy.io')
+    expect(b.hostname).toBe('http://companion.uppy.io')
+  })
+})

+ 617 - 0
packages/@uppy/companion-client/src/RequestClient.ts

@@ -0,0 +1,617 @@
+'use strict'
+
+import UserFacingApiError from '@uppy/utils/lib/UserFacingApiError'
+// eslint-disable-next-line import/no-extraneous-dependencies
+import pRetry, { AbortError } from 'p-retry'
+
+import fetchWithNetworkError from '@uppy/utils/lib/fetchWithNetworkError'
+import ErrorWithCause from '@uppy/utils/lib/ErrorWithCause'
+import emitSocketProgress from '@uppy/utils/lib/emitSocketProgress'
+import getSocketHost from '@uppy/utils/lib/getSocketHost'
+
+import type Uppy from '@uppy/core'
+import type { UppyFile, Meta, Body } from '@uppy/utils/lib/UppyFile'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped because we're getting rid of it
+import type { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
+import AuthError from './AuthError.ts'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
+import packageJson from '../package.json'
+
+type CompanionHeaders = Record<string, string> | undefined
+
+export type Opts = {
+  name?: string
+  provider: string
+  pluginId: string
+  companionUrl: string
+  companionCookiesRule?: 'same-origin' | 'include' | 'omit'
+  companionHeaders?: CompanionHeaders
+  companionKeysParams?: Record<string, string>
+}
+
+export type RequestOptions =
+  | boolean // TODO: remove this on the next major
+  | {
+      method?: string
+      data?: Record<string, unknown>
+      skipPostResponse?: boolean
+      signal?: AbortSignal
+      qs?: Record<string, string>
+    }
+
+// Remove the trailing slash so we can always safely append /xyz.
+function stripSlash(url: string) {
+  return url.replace(/\/$/, '')
+}
+
+const retryCount = 10 // set to a low number, like 2 to test manual user retries
+const socketActivityTimeoutMs = 5 * 60 * 1000 // set to a low number like 10000 to test this
+
+export const authErrorStatusCode = 401
+
+class HttpError extends Error {
+  statusCode: number
+
+  constructor({
+    statusCode,
+    message,
+  }: {
+    statusCode: number
+    message: string
+  }) {
+    super(message)
+    this.name = 'HttpError'
+    this.statusCode = statusCode
+  }
+}
+
+async function handleJSONResponse<ResJson extends Record<string, unknown>>(
+  res: Response,
+): Promise<ResJson> {
+  if (res.status === authErrorStatusCode) {
+    throw new AuthError()
+  }
+
+  if (res.ok) {
+    return res.json()
+  }
+
+  let errMsg = `Failed request with status: ${res.status}. ${res.statusText}`
+  let errData
+  try {
+    errData = await res.json()
+
+    if (errData.message) errMsg = `${errMsg} message: ${errData.message}`
+    if (errData.requestId) errMsg = `${errMsg} request-Id: ${errData.requestId}`
+  } catch (cause) {
+    // if the response contains invalid JSON, let's ignore the error data
+    throw new Error(errMsg, { cause })
+  }
+
+  if (res.status >= 400 && res.status <= 499 && errData.message) {
+    throw new UserFacingApiError(errData.message)
+  }
+
+  throw new HttpError({ statusCode: res.status, message: errMsg })
+}
+
+export default class RequestClient<M extends Meta, B extends Body> {
+  static VERSION = packageJson.version
+
+  #companionHeaders: CompanionHeaders
+
+  uppy: Uppy<M, B>
+
+  opts: Opts
+
+  constructor(uppy: Uppy<M, B>, opts: Opts) {
+    this.uppy = uppy
+    this.opts = opts
+    this.onReceiveResponse = this.onReceiveResponse.bind(this)
+    this.#companionHeaders = opts.companionHeaders
+  }
+
+  setCompanionHeaders(headers: Record<string, string>): void {
+    this.#companionHeaders = headers
+  }
+
+  private [Symbol.for('uppy test: getCompanionHeaders')](): CompanionHeaders {
+    return this.#companionHeaders
+  }
+
+  get hostname(): string {
+    const { companion } = this.uppy.getState()
+    const host = this.opts.companionUrl
+    return stripSlash(companion && companion[host] ? companion[host] : host)
+  }
+
+  async headers(emptyBody = false): Promise<Record<string, string>> {
+    const defaultHeaders = {
+      Accept: 'application/json',
+      ...(emptyBody
+        ? undefined
+        : {
+            // Passing those headers on requests with no data forces browsers to first make a preflight request.
+            'Content-Type': 'application/json',
+          }),
+    }
+
+    return {
+      ...defaultHeaders,
+      ...this.#companionHeaders,
+    }
+  }
+
+  onReceiveResponse(res: Response): void {
+    const { headers } = res
+    const state = this.uppy.getState()
+    const companion = state.companion || {}
+    const host = this.opts.companionUrl
+
+    // Store the self-identified domain name for the Companion instance we just hit.
+    if (headers.has('i-am') && headers.get('i-am') !== companion[host]) {
+      this.uppy.setState({
+        companion: { ...companion, [host]: headers.get('i-am') as string },
+      })
+    }
+  }
+
+  #getUrl(url: string) {
+    if (/^(https?:|)\/\//.test(url)) {
+      return url
+    }
+    return `${this.hostname}/${url}`
+  }
+
+  protected async request<ResBody extends Record<string, unknown>>({
+    path,
+    method = 'GET',
+    data,
+    skipPostResponse,
+    signal,
+  }: {
+    path: string
+    method?: string
+    data?: Record<string, unknown>
+    skipPostResponse?: boolean
+    signal?: AbortSignal
+  }): Promise<ResBody> {
+    try {
+      const headers = await this.headers(!data)
+      const response = await fetchWithNetworkError(this.#getUrl(path), {
+        method,
+        signal,
+        headers,
+        credentials: this.opts.companionCookiesRule || 'same-origin',
+        body: data ? JSON.stringify(data) : null,
+      })
+      if (!skipPostResponse) this.onReceiveResponse(response)
+
+      return await handleJSONResponse<ResBody>(response)
+    } catch (err) {
+      // pass these through
+      if (
+        err.isAuthError ||
+        err.name === 'UserFacingApiError' ||
+        err.name === 'AbortError'
+      )
+        throw err
+
+      throw new ErrorWithCause(`Could not ${method} ${this.#getUrl(path)}`, {
+        cause: err,
+      })
+    }
+  }
+
+  async get<PostBody extends Record<string, unknown>>(
+    path: string,
+    options?: RequestOptions,
+  ): Promise<PostBody> {
+    // TODO: remove boolean support for options that was added for backward compatibility.
+    // eslint-disable-next-line no-param-reassign
+    if (typeof options === 'boolean') options = { skipPostResponse: options }
+    return this.request({ ...options, path })
+  }
+
+  async post<PostBody extends Record<string, unknown>>(
+    path: string,
+    data: Record<string, unknown>,
+    options?: RequestOptions,
+  ): Promise<PostBody> {
+    // TODO: remove boolean support for options that was added for backward compatibility.
+    // eslint-disable-next-line no-param-reassign
+    if (typeof options === 'boolean') options = { skipPostResponse: options }
+    return this.request<PostBody>({ ...options, path, method: 'POST', data })
+  }
+
+  async delete(
+    path: string,
+    data: Record<string, unknown>,
+    options?: RequestOptions,
+  ): Promise<unknown> {
+    // TODO: remove boolean support for options that was added for backward compatibility.
+    // eslint-disable-next-line no-param-reassign
+    if (typeof options === 'boolean') options = { skipPostResponse: options }
+    return this.request({ ...options, path, method: 'DELETE', data })
+  }
+
+  /**
+   * Remote uploading consists of two steps:
+   * 1. #requestSocketToken which starts the download/upload in companion and returns a unique token for the upload.
+   * Then companion will halt the upload until:
+   * 2. #awaitRemoteFileUpload is called, which will open/ensure a websocket connection towards companion, with the
+   * previously generated token provided. It returns a promise that will resolve/reject once the file has finished
+   * uploading or is otherwise done (failed, canceled)
+   */
+  async uploadRemoteFile(
+    file: UppyFile<M, B>,
+    reqBody: Record<string, unknown>,
+    options: { signal: AbortSignal; getQueue: () => RateLimitedQueue },
+  ): Promise<void> {
+    try {
+      const { signal, getQueue } = options || {}
+
+      return await pRetry(
+        async () => {
+          // if we already have a serverToken, assume that we are resuming the existing server upload id
+          const existingServerToken = this.uppy.getFile(file.id)?.serverToken
+          if (existingServerToken != null) {
+            this.uppy.log(
+              `Connecting to exiting websocket ${existingServerToken}`,
+            )
+            return this.#awaitRemoteFileUpload({
+              file,
+              queue: getQueue(),
+              signal,
+            })
+          }
+
+          const queueRequestSocketToken = getQueue().wrapPromiseFunction(
+            async (
+              ...args: [
+                {
+                  file: UppyFile<M, B>
+                  postBody: Record<string, unknown>
+                  signal: AbortSignal
+                },
+              ]
+            ) => {
+              try {
+                return await this.#requestSocketToken(...args)
+              } catch (outerErr) {
+                // throwing AbortError will cause p-retry to stop retrying
+                if (outerErr.isAuthError) throw new AbortError(outerErr)
+
+                if (outerErr.cause == null) throw outerErr
+                const err = outerErr.cause
+
+                const isRetryableHttpError = () =>
+                  [408, 409, 429, 418, 423].includes(err.statusCode) ||
+                  (err.statusCode >= 500 &&
+                    err.statusCode <= 599 &&
+                    ![501, 505].includes(err.statusCode))
+                if (err.name === 'HttpError' && !isRetryableHttpError())
+                  throw new AbortError(err)
+
+                // p-retry will retry most other errors,
+                // but it will not retry TypeError (except network error TypeErrors)
+                throw err
+              }
+            },
+            { priority: -1 },
+          )
+
+          const serverToken = await queueRequestSocketToken({
+            file,
+            postBody: reqBody,
+            signal,
+          }).abortOn(signal)
+
+          if (!this.uppy.getFile(file.id)) return undefined // has file since been removed?
+
+          this.uppy.setFileState(file.id, { serverToken })
+
+          return this.#awaitRemoteFileUpload({
+            file: this.uppy.getFile(file.id), // re-fetching file because it might have changed in the meantime
+            queue: getQueue(),
+            signal,
+          })
+        },
+        {
+          retries: retryCount,
+          signal,
+          onFailedAttempt: (err) =>
+            this.uppy.log(`Retrying upload due to: ${err.message}`, 'warning'),
+        },
+      )
+    } catch (err) {
+      // this is a bit confusing, but note that an error with the `name` prop set to 'AbortError' (from AbortController)
+      // is not the same as `p-retry` `AbortError`
+      if (err.name === 'AbortError') {
+        // The file upload was aborted, it’s not an error
+        return undefined
+      }
+
+      this.uppy.emit('upload-error', file, err)
+      throw err
+    }
+  }
+
+  #requestSocketToken = async ({
+    file,
+    postBody,
+    signal,
+  }: {
+    file: UppyFile<M, B>
+    postBody: Record<string, unknown>
+    signal: AbortSignal
+  }): Promise<string> => {
+    if (file.remote?.url == null) {
+      throw new Error('Cannot connect to an undefined URL')
+    }
+
+    const res = await this.post(
+      file.remote.url,
+      {
+        ...file.remote.body,
+        ...postBody,
+      },
+      { signal },
+    )
+
+    return res.token as string
+  }
+
+  /**
+   * This method will ensure a websocket for the specified file and returns a promise that resolves
+   * when the file has finished downloading, or rejects if it fails.
+   * It will retry if the websocket gets disconnected
+   */
+  async #awaitRemoteFileUpload({
+    file,
+    queue,
+    signal,
+  }: {
+    file: UppyFile<M, B>
+    queue: RateLimitedQueue
+    signal: AbortSignal
+  }): Promise<void> {
+    let removeEventHandlers: () => void
+
+    const { capabilities } = this.uppy.getState()
+
+    try {
+      return await new Promise((resolve, reject) => {
+        const token = file.serverToken
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        const host = getSocketHost(file.remote!.companionUrl)
+
+        let socket: WebSocket | undefined
+        let socketAbortController: AbortController
+        let activityTimeout: ReturnType<typeof setTimeout>
+
+        let { isPaused } = file
+
+        const socketSend = (action: string, payload?: unknown) => {
+          if (socket == null || socket.readyState !== socket.OPEN) {
+            this.uppy.log(
+              `Cannot send "${action}" to socket ${
+                file.id
+              } because the socket state was ${String(socket?.readyState)}`,
+              'warning',
+            )
+            return
+          }
+
+          socket.send(
+            JSON.stringify({
+              action,
+              payload: payload ?? {},
+            }),
+          )
+        }
+
+        function sendState() {
+          if (!capabilities.resumableUploads) return
+
+          if (isPaused) socketSend('pause')
+          else socketSend('resume')
+        }
+
+        const createWebsocket = async () => {
+          if (socketAbortController) socketAbortController.abort()
+          socketAbortController = new AbortController()
+
+          const onFatalError = (err: Error) => {
+            // Remove the serverToken so that a new one will be created for the retry.
+            this.uppy.setFileState(file.id, { serverToken: null })
+            socketAbortController?.abort?.()
+            reject(err)
+          }
+
+          // todo instead implement the ability for users to cancel / retry *currently uploading files* in the UI
+          function resetActivityTimeout() {
+            clearTimeout(activityTimeout)
+            if (isPaused) return
+            activityTimeout = setTimeout(
+              () =>
+                onFatalError(
+                  new Error(
+                    'Timeout waiting for message from Companion socket',
+                  ),
+                ),
+              socketActivityTimeoutMs,
+            )
+          }
+
+          try {
+            await queue
+              .wrapPromiseFunction(async () => {
+                const reconnectWebsocket = async () =>
+                  // eslint-disable-next-line promise/param-names
+                  new Promise((_, rejectSocket) => {
+                    socket = new WebSocket(`${host}/api/${token}`)
+
+                    resetActivityTimeout()
+
+                    socket.addEventListener('close', () => {
+                      socket = undefined
+                      rejectSocket(new Error('Socket closed unexpectedly'))
+                    })
+
+                    socket.addEventListener('error', (error) => {
+                      this.uppy.log(
+                        `Companion socket error ${JSON.stringify(
+                          error,
+                        )}, closing socket`,
+                        'warning',
+                      )
+                      socket?.close() // will 'close' event to be emitted
+                    })
+
+                    socket.addEventListener('open', () => {
+                      sendState()
+                    })
+
+                    socket.addEventListener('message', (e) => {
+                      resetActivityTimeout()
+
+                      try {
+                        const { action, payload } = JSON.parse(e.data)
+
+                        switch (action) {
+                          case 'progress': {
+                            emitSocketProgress(this, payload, file)
+                            break
+                          }
+                          case 'success': {
+                            // @ts-expect-error event expects a lot more data.
+                            // TODO: add missing data?
+                            this.uppy.emit('upload-success', file, {
+                              uploadURL: payload.url,
+                            })
+                            socketAbortController?.abort?.()
+                            resolve()
+                            break
+                          }
+                          case 'error': {
+                            const { message } = payload.error
+                            throw Object.assign(new Error(message), {
+                              cause: payload.error,
+                            })
+                          }
+                          default:
+                            this.uppy.log(
+                              `Companion socket unknown action ${action}`,
+                              'warning',
+                            )
+                        }
+                      } catch (err) {
+                        onFatalError(err)
+                      }
+                    })
+
+                    const closeSocket = () => {
+                      this.uppy.log(`Closing socket ${file.id}`, 'info')
+                      clearTimeout(activityTimeout)
+                      if (socket) socket.close()
+                      socket = undefined
+                    }
+
+                    socketAbortController.signal.addEventListener(
+                      'abort',
+                      () => {
+                        closeSocket()
+                      },
+                    )
+                  })
+
+                await pRetry(reconnectWebsocket, {
+                  retries: retryCount,
+                  signal: socketAbortController.signal,
+                  onFailedAttempt: () => {
+                    if (socketAbortController.signal.aborted) return // don't log in this case
+                    this.uppy.log(`Retrying websocket ${file.id}`, 'info')
+                  },
+                })
+              })()
+              .abortOn(socketAbortController.signal)
+          } catch (err) {
+            if (socketAbortController.signal.aborted) return
+            onFatalError(err)
+          }
+        }
+
+        const pause = (newPausedState: boolean) => {
+          if (!capabilities.resumableUploads) return
+
+          isPaused = newPausedState
+          if (socket) sendState()
+
+          if (newPausedState) {
+            // Remove this file from the queue so another file can start in its place.
+            socketAbortController?.abort?.() // close socket to free up the request for other uploads
+          } else {
+            // Resuming an upload should be queued, else you could pause and then
+            // resume a queued upload to make it skip the queue.
+            createWebsocket()
+          }
+        }
+
+        const onFileRemove = (targetFile: UppyFile<M, B>) => {
+          if (!capabilities.individualCancellation) return
+          if (targetFile.id !== file.id) return
+          socketSend('cancel')
+          socketAbortController?.abort?.()
+          this.uppy.log(`upload ${file.id} was removed`, 'info')
+          resolve()
+        }
+
+        const onCancelAll = ({ reason }: { reason?: string }) => {
+          if (reason === 'user') {
+            socketSend('cancel')
+          }
+          socketAbortController?.abort?.()
+          this.uppy.log(`upload ${file.id} was canceled`, 'info')
+          resolve()
+        }
+
+        const onFilePausedChange = (
+          targetFileId: string | undefined,
+          newPausedState: boolean,
+        ) => {
+          if (targetFileId !== file.id) return
+          pause(newPausedState)
+        }
+
+        const onPauseAll = () => pause(true)
+        const onResumeAll = () => pause(false)
+
+        this.uppy.on('file-removed', onFileRemove)
+        this.uppy.on('cancel-all', onCancelAll)
+        this.uppy.on('upload-pause', onFilePausedChange)
+        this.uppy.on('pause-all', onPauseAll)
+        this.uppy.on('resume-all', onResumeAll)
+
+        removeEventHandlers = () => {
+          this.uppy.off('file-removed', onFileRemove)
+          this.uppy.off('cancel-all', onCancelAll)
+          this.uppy.off('upload-pause', onFilePausedChange)
+          this.uppy.off('pause-all', onPauseAll)
+          this.uppy.off('resume-all', onResumeAll)
+        }
+
+        signal.addEventListener('abort', () => {
+          socketAbortController?.abort()
+        })
+
+        createWebsocket()
+      })
+    } finally {
+      // @ts-expect-error used before defined
+      removeEventHandlers?.()
+    }
+  }
+}

+ 0 - 25
packages/@uppy/companion-client/src/SearchProvider.js

@@ -1,25 +0,0 @@
-'use strict'
-
-import RequestClient from './RequestClient.js'
-
-const getName = (id) => {
-  return id.split('-').map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(' ')
-}
-
-export default class SearchProvider extends RequestClient {
-  constructor (uppy, opts) {
-    super(uppy, opts)
-    this.provider = opts.provider
-    this.id = this.provider
-    this.name = this.opts.name || getName(this.id)
-    this.pluginId = this.opts.pluginId
-  }
-
-  fileUrl (id) {
-    return `${this.hostname}/search/${this.id}/get/${id}`
-  }
-
-  search (text, queries) {
-    return this.get(`search/${this.id}/list?q=${encodeURIComponent(text)}${queries ? `&${queries}` : ''}`)
-  }
-}

+ 48 - 0
packages/@uppy/companion-client/src/SearchProvider.ts

@@ -0,0 +1,48 @@
+'use strict'
+
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile.ts'
+import type { Uppy } from '@uppy/core'
+import RequestClient, { type Opts } from './RequestClient.ts'
+
+const getName = (id: string): string => {
+  return id
+    .split('-')
+    .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
+    .join(' ')
+}
+
+export default class SearchProvider<
+  M extends Meta,
+  B extends Body,
+> extends RequestClient<M, B> {
+  provider: string
+
+  id: string
+
+  name: string
+
+  pluginId: string
+
+  constructor(uppy: Uppy<M, B>, opts: Opts) {
+    super(uppy, opts)
+    this.provider = opts.provider
+    this.id = this.provider
+    this.name = this.opts.name || getName(this.id)
+    this.pluginId = this.opts.pluginId
+  }
+
+  fileUrl(id: string): string {
+    return `${this.hostname}/search/${this.id}/get/${id}`
+  }
+
+  search<ResBody extends Record<string, unknown>>(
+    text: string,
+    queries?: string,
+  ): Promise<ResBody> {
+    return this.get<ResBody>(
+      `search/${this.id}/list?q=${encodeURIComponent(text)}${
+        queries ? `&${queries}` : ''
+      }`,
+    )
+  }
+}

+ 38 - 12
packages/@uppy/companion-client/src/Socket.test.js → packages/@uppy/companion-client/src/Socket.test.ts

@@ -1,41 +1,53 @@
-import { afterEach, beforeEach, vi, describe, it, expect } from 'vitest'
-import UppySocket from './Socket.js'
+import {
+  afterEach,
+  beforeEach,
+  vi,
+  describe,
+  it,
+  expect,
+  type Mock,
+} from 'vitest'
+import UppySocket from './Socket.ts'
 
 describe('Socket', () => {
-  let webSocketConstructorSpy
-  let webSocketCloseSpy
-  let webSocketSendSpy
+  let webSocketConstructorSpy: Mock
+  let webSocketCloseSpy: Mock
+  let webSocketSendSpy: Mock
 
   beforeEach(() => {
     webSocketConstructorSpy = vi.fn()
     webSocketCloseSpy = vi.fn()
     webSocketSendSpy = vi.fn()
 
+    // @ts-expect-error WebSocket expects a lot more to be present but we don't care for this test
     globalThis.WebSocket = class WebSocket {
-      constructor (target) {
+      constructor(target: string) {
         webSocketConstructorSpy(target)
       }
 
       // eslint-disable-next-line class-methods-use-this
-      close (args) {
+      close(args: any) {
         webSocketCloseSpy(args)
       }
 
       // eslint-disable-next-line class-methods-use-this
-      send (json) {
+      send(json: any) {
         webSocketSendSpy(json)
       }
 
-      triggerOpen () {
+      triggerOpen() {
+        // @ts-expect-error exist
         this.onopen()
       }
 
-      triggerClose () {
+      triggerClose() {
+        // @ts-expect-error exist
         this.onclose()
       }
     }
   })
   afterEach(() => {
+    // @ts-expect-error not allowed but needed for test
     globalThis.WebSocket = undefined
   })
 
@@ -55,6 +67,7 @@ describe('Socket', () => {
 
   it('should send a message via the websocket if the connection is open', () => {
     const uppySocket = new UppySocket({ target: 'foo' })
+    // @ts-expect-error not allowed but needed for test
     const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]()
     webSocketInstance.triggerOpen()
 
@@ -69,16 +82,21 @@ describe('Socket', () => {
     const uppySocket = new UppySocket({ target: 'foo' })
 
     uppySocket.send('bar', 'boo')
-    expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([{ action: 'bar', payload: 'boo' }])
+    // @ts-expect-error not allowed but needed for test
+    expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([
+      { action: 'bar', payload: 'boo' },
+    ])
     expect(webSocketSendSpy.mock.calls.length).toEqual(0)
   })
 
   it('should queue any messages for the websocket if the connection is not open, then send them when the connection is open', () => {
     const uppySocket = new UppySocket({ target: 'foo' })
+    // @ts-expect-error not allowed but needed for test
     const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]()
 
     uppySocket.send('bar', 'boo')
     uppySocket.send('moo', 'baa')
+    // @ts-expect-error not allowed but needed for test
     expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([
       { action: 'bar', payload: 'boo' },
       { action: 'moo', payload: 'baa' },
@@ -87,6 +105,7 @@ describe('Socket', () => {
 
     webSocketInstance.triggerOpen()
 
+    // @ts-expect-error not allowed but needed for test
     expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([])
     expect(webSocketSendSpy.mock.calls.length).toEqual(2)
     expect(webSocketSendSpy.mock.calls[0]).toEqual([
@@ -99,18 +118,24 @@ describe('Socket', () => {
 
   it('should start queuing any messages when the websocket connection is closed', () => {
     const uppySocket = new UppySocket({ target: 'foo' })
+    // @ts-expect-error not allowed but needed for test
     const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]()
     webSocketInstance.triggerOpen()
     uppySocket.send('bar', 'boo')
+    // @ts-expect-error not allowed but needed for test
     expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([])
 
     webSocketInstance.triggerClose()
     uppySocket.send('bar', 'boo')
-    expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([{ action: 'bar', payload: 'boo' }])
+    // @ts-expect-error not allowed but needed for test
+    expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([
+      { action: 'bar', payload: 'boo' },
+    ])
   })
 
   it('should close the websocket when it is force closed', () => {
     const uppySocket = new UppySocket({ target: 'foo' })
+    // @ts-expect-error not allowed but needed for test
     const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]()
     webSocketInstance.triggerOpen()
 
@@ -120,6 +145,7 @@ describe('Socket', () => {
 
   it('should be able to subscribe to messages received on the websocket', () => {
     const uppySocket = new UppySocket({ target: 'foo' })
+    // @ts-expect-error not allowed but needed for test
     const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]()
 
     const emitterListenerMock = vi.fn()

+ 39 - 18
packages/@uppy/companion-client/src/Socket.js → packages/@uppy/companion-client/src/Socket.ts

@@ -1,15 +1,24 @@
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore no types
 import ee from 'namespace-emitter'
 
+type Opts = {
+  autoOpen?: boolean
+  target: string
+}
+
 export default class UppySocket {
-  #queued = []
+  #queued: Array<{ action: string; payload: unknown }> = []
 
   #emitter = ee()
 
   #isOpen = false
 
-  #socket
+  #socket: WebSocket | null
+
+  opts: Opts
 
-  constructor (opts) {
+  constructor(opts: Opts) {
     this.opts = opts
 
     if (!opts || opts.autoOpen !== false) {
@@ -17,13 +26,22 @@ export default class UppySocket {
     }
   }
 
-  get isOpen () { return this.#isOpen }
+  get isOpen(): boolean {
+    return this.#isOpen
+  }
 
-  [Symbol.for('uppy test: getSocket')] () { return this.#socket }
+  private [Symbol.for('uppy test: getSocket')](): WebSocket | null {
+    return this.#socket
+  }
 
-  [Symbol.for('uppy test: getQueued')] () { return this.#queued }
+  private [Symbol.for('uppy test: getQueued')](): Array<{
+    action: string
+    payload: unknown
+  }> {
+    return this.#queued
+  }
 
-  open () {
+  open(): void {
     if (this.#socket != null) return
 
     this.#socket = new WebSocket(this.opts.target)
@@ -33,7 +51,8 @@ export default class UppySocket {
 
       while (this.#queued.length > 0 && this.#isOpen) {
         const first = this.#queued.shift()
-        this.send(first.action, first.payload)
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        this.send(first!.action, first!.payload)
       }
     }
 
@@ -45,11 +64,11 @@ export default class UppySocket {
     this.#socket.onmessage = this.#handleMessage
   }
 
-  close () {
+  close(): void {
     this.#socket?.close()
   }
 
-  send (action, payload) {
+  send(action: string, payload: unknown): void {
     // attach uuid
 
     if (!this.#isOpen) {
@@ -57,25 +76,27 @@ export default class UppySocket {
       return
     }
 
-    this.#socket.send(JSON.stringify({
-      action,
-      payload,
-    }))
+    this.#socket!.send(
+      JSON.stringify({
+        action,
+        payload,
+      }),
+    )
   }
 
-  on (action, handler) {
+  on(action: string, handler: () => void): void {
     this.#emitter.on(action, handler)
   }
 
-  emit (action, payload) {
+  emit(action: string, payload: unknown): void {
     this.#emitter.emit(action, payload)
   }
 
-  once (action, handler) {
+  once(action: string, handler: () => void): void {
     this.#emitter.once(action, handler)
   }
 
-  #handleMessage = (e) => {
+  #handleMessage = (e: MessageEvent<any>) => {
     try {
       const message = JSON.parse(e.data)
       this.emit(message.action, message.payload)

+ 0 - 12
packages/@uppy/companion-client/src/index.js

@@ -1,12 +0,0 @@
-'use strict'
-
-/**
- * Manages communications with Companion
- */
-
-export { default as RequestClient } from './RequestClient.js'
-export { default as Provider } from './Provider.js'
-export { default as SearchProvider } from './SearchProvider.js'
-
-// TODO: remove in the next major
-export { default as Socket } from './Socket.js'

+ 12 - 0
packages/@uppy/companion-client/src/index.ts

@@ -0,0 +1,12 @@
+'use strict'
+
+/**
+ * Manages communications with Companion
+ */
+
+export { default as RequestClient } from './RequestClient.ts'
+export { default as Provider } from './Provider.ts'
+export { default as SearchProvider } from './SearchProvider.ts'
+
+// TODO: remove in the next major
+export { default as Socket } from './Socket.ts'

+ 3 - 3
packages/@uppy/companion-client/src/tokenStorage.js → packages/@uppy/companion-client/src/tokenStorage.ts

@@ -3,18 +3,18 @@
 /**
  * This module serves as an Async wrapper for LocalStorage
  */
-export function setItem (key, value) {
+export function setItem(key: string, value: string): Promise<void> {
   return new Promise((resolve) => {
     localStorage.setItem(key, value)
     resolve()
   })
 }
 
-export function getItem (key) {
+export function getItem(key: string): Promise<string | null> {
   return Promise.resolve(localStorage.getItem(key))
 }
 
-export function removeItem (key) {
+export function removeItem(key: string): Promise<void> {
   return new Promise((resolve) => {
     localStorage.removeItem(key)
     resolve()

+ 25 - 0
packages/@uppy/companion-client/tsconfig.build.json

@@ -0,0 +1,25 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    },
+    "resolveJsonModule": false,
+    "rootDir": "./src",
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*.*"],
+  "exclude": ["./src/**/*.test.ts"],
+  "references": [
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 21 - 0
packages/@uppy/companion-client/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "emitDeclarationOnly": false,
+    "noEmit": true,
+    "paths": {
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    }
+  },
+  "include": ["./package.json", "./src/**/*.*"],
+  "references": [
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 3 - 2
packages/@uppy/core/src/Uppy.ts

@@ -46,7 +46,7 @@ type FileRemoveReason = 'user' | 'cancel-all'
 
 type LogLevel = 'info' | 'warning' | 'error' | 'success'
 
-type UnknownPlugin<M extends Meta, B extends Body> = InstanceType<
+export type UnknownPlugin<M extends Meta, B extends Body> = InstanceType<
   typeof BasePlugin<any, M, B> | typeof UIPlugin<any, M, B>
 >
 
@@ -110,6 +110,7 @@ export interface State<M extends Meta, B extends Body>
   }>
   plugins: Plugins
   totalProgress: number
+  companion?: Record<string, string>
 }
 
 export interface UppyOptions<M extends Meta, B extends Body> {
@@ -211,7 +212,7 @@ type ErrorCallback<M extends Meta, B extends Body> = (
 type UploadErrorCallback<M extends Meta, B extends Body> = (
   file: UppyFile<M, B> | undefined,
   error: { message: string; details?: string },
-  response: UppyFile<M, B>['response'] | undefined,
+  response?: UppyFile<M, B>['response'] | undefined,
 ) => void
 type UploadStalledCallback<M extends Meta, B extends Body> = (
   error: { message: string; details?: string },

+ 1 - 1
packages/@uppy/utils/src/UppyFile.ts

@@ -28,7 +28,7 @@ export interface UppyFile<M extends Meta, B extends Body> {
     requestClientId: string
     url: string
   }
-  serverToken?: string
+  serverToken?: string | null
   size: number | null
   source?: string
   type?: string

+ 0 - 0
packages/@uppy/utils/src/UserFacingApiError.js → packages/@uppy/utils/src/UserFacingApiError.ts


+ 2 - 0
yarn.lock

@@ -9163,6 +9163,8 @@ __metadata:
     namespace-emitter: ^2.0.1
     p-retry: ^6.1.0
     vitest: ^1.2.1
+  peerDependencies:
+    "@uppy/core": "workspace:^"
   languageName: unknown
   linkType: soft