diff --git a/packages/@uppy/companion-client/package.json b/packages/@uppy/companion-client/package.json index 459eaf025..3f6ae215f 100644 --- a/packages/@uppy/companion-client/package.json +++ b/packages/@uppy/companion-client/package.json @@ -28,5 +28,8 @@ }, "devDependencies": { "vitest": "^1.2.1" + }, + "peerDependencies": { + "@uppy/core": "workspace:^" } } diff --git a/packages/@uppy/companion-client/src/AuthError.js b/packages/@uppy/companion-client/src/AuthError.ts similarity index 93% rename from packages/@uppy/companion-client/src/AuthError.js rename to packages/@uppy/companion-client/src/AuthError.ts index 6c77e794f..627d1f5d3 100644 --- a/packages/@uppy/companion-client/src/AuthError.js +++ b/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' diff --git a/packages/@uppy/companion-client/src/Provider.js b/packages/@uppy/companion-client/src/Provider.ts similarity index 50% rename from packages/@uppy/companion-client/src/Provider.js rename to packages/@uppy/companion-client/src/Provider.ts index 3edb0a989..749daa269 100644 --- a/packages/@uppy/companion-client/src/Provider.js +++ b/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' -import RequestClient, { authErrorStatusCode } from './RequestClient.js' -import * as tokenStorage from './tokenStorage.js' +// TODO: remove deprecated options in next major release +export type Opts = { + /** @deprecated */ + serverUrl?: string + /** @deprecated */ + serverPattern?: string + companionUrl: string + companionAllowedHosts?: string | RegExp | Array + storage?: typeof tokenStorage + pluginId: string + name?: string + supportsRefreshToken?: boolean + provider: string +} +interface ProviderPlugin + extends BasePlugin { + files: UppyFile[] -const getName = (id) => { - return id.split('-').map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(' ') + storage: typeof tokenStorage +} + +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 | 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 { + #refreshingTokenPromise: Promise | undefined - constructor(uppy, opts) { + provider: string + + id: string + + name: string + + pluginId: string + + tokenKey: string + + companionKeysParams?: Record + + preAuthToken: string | null + + supportsRefreshToken: boolean + + constructor(uppy: Uppy, 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> { + const [headers, token] = await Promise.all([ + super.headers(), + this.#getAuthToken(), + ]) + const authHeaders: Record = {} 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 { + return this.#getPlugin().storage.setItem(this.tokenKey, token) } - async #getAuthToken() { - return this.uppy.getPlugin(this.pluginId).storage.getItem(this.tokenKey) + async #getAuthToken(): Promise { + return this.#getPlugin().storage.getItem(this.tokenKey) } - /** @protected */ - async removeAuthToken() { - return this.uppy.getPlugin(this.pluginId).storage.removeItem(this.tokenKey) + protected async removeAuthToken(): Promise { + return this.#getPlugin().storage.removeItem(this.tokenKey) + } + + #getPlugin() { + const plugin = this.uppy.getPlugin(this.pluginId) as ProviderPlugin + 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 { 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 { return {} } - authUrl({ authFormData, query } = {}) { + authUrl({ + authFormData, + query, + }: { + authFormData: unknown + query: Record + }): 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 { + type Res = { uppyAuthToken: string } + const response = await this.post( + `${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 { 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) => { 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 { 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>( + ...args: Parameters['request']> + ): Promise { 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(...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 { 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>( + directory: string | undefined, + options: RequestOptions, + ): Promise { + return this.get(`${this.id}/list/${directory || ''}`, options) } - async logout(options) { - const response = await this.get(`${this.id}/logout`, options) + async logout>( + options: RequestOptions, + ): Promise { + const response = await this.get(`${this.id}/logout`, options) await this.removeAuthToken() return response } - static initPlugin(plugin, opts, defaultOpts) { + static initPlugin( + plugin: ProviderPlugin, // any because static methods cannot use class generics + opts: Opts, + defaultOpts: Record, + ): 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 } diff --git a/packages/@uppy/companion-client/src/RequestClient.js b/packages/@uppy/companion-client/src/RequestClient.js deleted file mode 100644 index c0fae7406..000000000 --- a/packages/@uppy/companion-client/src/RequestClient.js +++ /dev/null @@ -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?.() - } - } -} diff --git a/packages/@uppy/companion-client/src/RequestClient.test.js b/packages/@uppy/companion-client/src/RequestClient.test.js deleted file mode 100644 index b1817827c..000000000 --- a/packages/@uppy/companion-client/src/RequestClient.test.js +++ /dev/null @@ -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') - }) -}) diff --git a/packages/@uppy/companion-client/src/RequestClient.test.ts b/packages/@uppy/companion-client/src/RequestClient.test.ts new file mode 100644 index 000000000..2277387c4 --- /dev/null +++ b/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') + }) +}) diff --git a/packages/@uppy/companion-client/src/RequestClient.ts b/packages/@uppy/companion-client/src/RequestClient.ts new file mode 100644 index 000000000..42be57231 --- /dev/null +++ b/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 | undefined + +export type Opts = { + name?: string + provider: string + pluginId: string + companionUrl: string + companionCookiesRule?: 'same-origin' | 'include' | 'omit' + companionHeaders?: CompanionHeaders + companionKeysParams?: Record +} + +export type RequestOptions = + | boolean // TODO: remove this on the next major + | { + method?: string + data?: Record + skipPostResponse?: boolean + signal?: AbortSignal + qs?: Record + } + +// 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>( + res: Response, +): Promise { + 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: CompanionHeaders + + uppy: Uppy + + opts: Opts + + constructor(uppy: Uppy, opts: Opts) { + this.uppy = uppy + this.opts = opts + this.onReceiveResponse = this.onReceiveResponse.bind(this) + this.#companionHeaders = opts.companionHeaders + } + + setCompanionHeaders(headers: Record): 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> { + 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>({ + path, + method = 'GET', + data, + skipPostResponse, + signal, + }: { + path: string + method?: string + data?: Record + skipPostResponse?: boolean + signal?: AbortSignal + }): Promise { + 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: string, + options?: RequestOptions, + ): Promise { + // 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: string, + data: Record, + options?: RequestOptions, + ): Promise { + // 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: string, + data: Record, + options?: RequestOptions, + ): Promise { + // 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, + reqBody: Record, + options: { signal: AbortSignal; getQueue: () => RateLimitedQueue }, + ): Promise { + 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 + postBody: Record + 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 + postBody: Record + signal: AbortSignal + }): Promise => { + 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 + queue: RateLimitedQueue + signal: AbortSignal + }): Promise { + 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 + + 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) => { + 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?.() + } + } +} diff --git a/packages/@uppy/companion-client/src/SearchProvider.js b/packages/@uppy/companion-client/src/SearchProvider.js deleted file mode 100644 index 19bc051d7..000000000 --- a/packages/@uppy/companion-client/src/SearchProvider.js +++ /dev/null @@ -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}` : ''}`) - } -} diff --git a/packages/@uppy/companion-client/src/SearchProvider.ts b/packages/@uppy/companion-client/src/SearchProvider.ts new file mode 100644 index 000000000..9ad70db56 --- /dev/null +++ b/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 { + provider: string + + id: string + + name: string + + pluginId: string + + constructor(uppy: Uppy, 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>( + text: string, + queries?: string, + ): Promise { + return this.get( + `search/${this.id}/list?q=${encodeURIComponent(text)}${ + queries ? `&${queries}` : '' + }`, + ) + } +} diff --git a/packages/@uppy/companion-client/src/Socket.test.js b/packages/@uppy/companion-client/src/Socket.test.ts similarity index 80% rename from packages/@uppy/companion-client/src/Socket.test.js rename to packages/@uppy/companion-client/src/Socket.test.ts index 97e46dbd2..aa79afeb9 100644 --- a/packages/@uppy/companion-client/src/Socket.test.js +++ b/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() diff --git a/packages/@uppy/companion-client/src/Socket.js b/packages/@uppy/companion-client/src/Socket.ts similarity index 51% rename from packages/@uppy/companion-client/src/Socket.js rename to packages/@uppy/companion-client/src/Socket.ts index 0a98200d7..3de816b8f 100644 --- a/packages/@uppy/companion-client/src/Socket.js +++ b/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 - constructor (opts) { + opts: 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) => { try { const message = JSON.parse(e.data) this.emit(message.action, message.payload) diff --git a/packages/@uppy/companion-client/src/index.js b/packages/@uppy/companion-client/src/index.js deleted file mode 100644 index f3dddcaf6..000000000 --- a/packages/@uppy/companion-client/src/index.js +++ /dev/null @@ -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' diff --git a/packages/@uppy/companion-client/src/index.ts b/packages/@uppy/companion-client/src/index.ts new file mode 100644 index 000000000..1d53f5f29 --- /dev/null +++ b/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' diff --git a/packages/@uppy/companion-client/src/tokenStorage.js b/packages/@uppy/companion-client/src/tokenStorage.ts similarity index 62% rename from packages/@uppy/companion-client/src/tokenStorage.js rename to packages/@uppy/companion-client/src/tokenStorage.ts index 2bfc9f587..0ce6a3a36 100644 --- a/packages/@uppy/companion-client/src/tokenStorage.js +++ b/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 { return new Promise((resolve) => { localStorage.setItem(key, value) resolve() }) } -export function getItem (key) { +export function getItem(key: string): Promise { return Promise.resolve(localStorage.getItem(key)) } -export function removeItem (key) { +export function removeItem(key: string): Promise { return new Promise((resolve) => { localStorage.removeItem(key) resolve() diff --git a/packages/@uppy/companion-client/tsconfig.build.json b/packages/@uppy/companion-client/tsconfig.build.json new file mode 100644 index 000000000..1b0ca4109 --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/@uppy/companion-client/tsconfig.json b/packages/@uppy/companion-client/tsconfig.json new file mode 100644 index 000000000..335ddda84 --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index dacfbf295..1f375d72a 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -46,7 +46,7 @@ type FileRemoveReason = 'user' | 'cancel-all' type LogLevel = 'info' | 'warning' | 'error' | 'success' -type UnknownPlugin = InstanceType< +export type UnknownPlugin = InstanceType< typeof BasePlugin | typeof UIPlugin > @@ -110,6 +110,7 @@ export interface State }> plugins: Plugins totalProgress: number + companion?: Record } export interface UppyOptions { @@ -211,7 +212,7 @@ type ErrorCallback = ( type UploadErrorCallback = ( file: UppyFile | undefined, error: { message: string; details?: string }, - response: UppyFile['response'] | undefined, + response?: UppyFile['response'] | undefined, ) => void type UploadStalledCallback = ( error: { message: string; details?: string }, diff --git a/packages/@uppy/utils/src/UppyFile.ts b/packages/@uppy/utils/src/UppyFile.ts index 6f40e411e..ad910ebbc 100644 --- a/packages/@uppy/utils/src/UppyFile.ts +++ b/packages/@uppy/utils/src/UppyFile.ts @@ -28,7 +28,7 @@ export interface UppyFile { requestClientId: string url: string } - serverToken?: string + serverToken?: string | null size: number | null source?: string type?: string diff --git a/packages/@uppy/utils/src/UserFacingApiError.js b/packages/@uppy/utils/src/UserFacingApiError.ts similarity index 100% rename from packages/@uppy/utils/src/UserFacingApiError.js rename to packages/@uppy/utils/src/UserFacingApiError.ts diff --git a/yarn.lock b/yarn.lock index ddaccc0b2..e2ce28f66 100644 --- a/yarn.lock +++ b/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