UNPKG

@uppy/companion-client

Version:

Client library for communication with Companion. Intended for use in Uppy plugins.

617 lines (533 loc) 19.8 kB
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 getSocketHost from '@uppy/utils/lib/getSocketHost' import type Uppy from '@uppy/core' import type { UppyFile, Meta, Body } from '@uppy/utils/lib/UppyFile' import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider' import AuthError from './AuthError.js' // 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> } // 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>(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 }) } function emitSocketProgress( uploader: { uppy: Uppy<any, any> }, progressData: { progress: string // pre-formatted percentage number as a string bytesTotal: number bytesUploaded: number }, file: UppyFile<any, any>, ): void { const { progress, bytesUploaded, bytesTotal } = progressData if (progress) { uploader.uppy.log(`Upload progress: ${progress}`) uploader.uppy.emit('upload-progress', file, { uploadStarted: file.progress.uploadStarted ?? 0, bytesUploaded, bytesTotal, }) } } 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>({ 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>( path: string, options?: RequestOptions, ): Promise<PostBody> { return this.request({ ...options, path }) } async post<PostBody>( path: string, data: Record<string, unknown>, options?: RequestOptions, ): Promise<PostBody> { return this.request<PostBody>({ ...options, path, method: 'POST', data }) } async delete<T>( path: string, data?: Record<string, unknown>, options?: RequestOptions, ): Promise<T> { 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: () => any }, ): 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<{ token: string }>( 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 */ async #awaitRemoteFileUpload({ file, queue, signal, }: { file: UppyFile<M, B> queue: any signal: AbortSignal }): Promise<void> { let removeEventHandlers: () => void const { capabilities } = this.uppy.getState() try { return await new Promise((resolve, reject) => { const token = file.serverToken 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, this.uppy.getFile(file.id), ) break } case 'success': { // payload.response is sent from companion for xhr-upload (aka uploadMultipart in companion) and // s3 multipart (aka uploadS3Multipart) // but not for tus/transloadit (aka uploadTus) // responseText is a string which may or may not be in JSON format // this means that an upload destination of xhr or s3 multipart MUST respond with valid JSON // to companion, or the JSON.parse will crash const text = payload.response?.responseText this.uppy.emit( 'upload-success', this.uppy.getFile(file.id), { uploadURL: payload.url, status: payload.response?.status ?? 200, body: text ? (JSON.parse(text) as B) : undefined, }, ) 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}`) 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}`) }, }) })() .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() } 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`) resolve() } const onCancelAll = () => { socketSend('cancel') socketAbortController?.abort?.() this.uppy.log(`upload ${file.id} was canceled`) resolve() } const onFilePausedChange = ( targetFile: UppyFile<M, B> | undefined, newPausedState: boolean, ) => { if (targetFile?.id !== 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?.() } } }