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

This commit is contained in:
Merlijn Vos 2024-01-24 14:19:38 +01:00 committed by GitHub
parent 588bfb515e
commit a0050bd79f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1035 additions and 610 deletions

View file

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

View file

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

View file

@ -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<string | RegExp>
storage?: typeof tokenStorage
pluginId: string
name?: string
supportsRefreshToken?: boolean
provider: string
}
interface ProviderPlugin<M extends Meta, B extends Body>
extends BasePlugin<Opts, M, B> {
files: UppyFile<M, B>[]
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<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
constructor(uppy, opts) {
provider: string
id: string
name: string
pluginId: string
tokenKey: string
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
}

View file

@ -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, its 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?.()
}
}
}

View file

@ -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')
})
})

View file

@ -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')
})
})

View file

@ -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, its 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?.()
}
}
}

View file

@ -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}` : ''}`)
}
}

View file

@ -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}` : ''
}`,
)
}
}

View file

@ -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()

View file

@ -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<any>) => {
try {
const message = JSON.parse(e.data)
this.emit(message.action, message.payload)

View file

@ -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'

View file

@ -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'

View file

@ -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()

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -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 },

View file

@ -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

View file

@ -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