mirror of
https://github.com/transloadit/uppy.git
synced 2026-07-17 16:50:22 +00:00
@uppy/companion-client: migrate to TS (#4864)
This commit is contained in:
parent
588bfb515e
commit
a0050bd79f
20 changed files with 1035 additions and 610 deletions
|
|
@ -28,5 +28,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
'use strict'
|
||||
|
||||
class AuthError extends Error {
|
||||
isAuthError: boolean
|
||||
|
||||
constructor() {
|
||||
super('Authorization required')
|
||||
this.name = 'AuthError'
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import RequestClient from './RequestClient.js'
|
||||
|
||||
describe('RequestClient', () => {
|
||||
it('has a hostname without trailing slash', () => {
|
||||
const mockCore = { getState: () => ({}) }
|
||||
const a = new RequestClient(mockCore, { companionUrl: 'http://companion.uppy.io' })
|
||||
const b = new RequestClient(mockCore, { companionUrl: 'http://companion.uppy.io/' })
|
||||
|
||||
expect(a.hostname).toBe('http://companion.uppy.io')
|
||||
expect(b.hostname).toBe('http://companion.uppy.io')
|
||||
})
|
||||
})
|
||||
21
packages/@uppy/companion-client/src/RequestClient.test.ts
Normal file
21
packages/@uppy/companion-client/src/RequestClient.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
617
packages/@uppy/companion-client/src/RequestClient.ts
Normal file
617
packages/@uppy/companion-client/src/RequestClient.ts
Normal 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, it’s not an error
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.uppy.emit('upload-error', file, err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
#requestSocketToken = async ({
|
||||
file,
|
||||
postBody,
|
||||
signal,
|
||||
}: {
|
||||
file: UppyFile<M, B>
|
||||
postBody: Record<string, unknown>
|
||||
signal: AbortSignal
|
||||
}): Promise<string> => {
|
||||
if (file.remote?.url == null) {
|
||||
throw new Error('Cannot connect to an undefined URL')
|
||||
}
|
||||
|
||||
const res = await this.post(
|
||||
file.remote.url,
|
||||
{
|
||||
...file.remote.body,
|
||||
...postBody,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
|
||||
return res.token as string
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will ensure a websocket for the specified file and returns a promise that resolves
|
||||
* when the file has finished downloading, or rejects if it fails.
|
||||
* It will retry if the websocket gets disconnected
|
||||
*/
|
||||
async #awaitRemoteFileUpload({
|
||||
file,
|
||||
queue,
|
||||
signal,
|
||||
}: {
|
||||
file: UppyFile<M, B>
|
||||
queue: RateLimitedQueue
|
||||
signal: AbortSignal
|
||||
}): Promise<void> {
|
||||
let removeEventHandlers: () => void
|
||||
|
||||
const { capabilities } = this.uppy.getState()
|
||||
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const token = file.serverToken
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const host = getSocketHost(file.remote!.companionUrl)
|
||||
|
||||
let socket: WebSocket | undefined
|
||||
let socketAbortController: AbortController
|
||||
let activityTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
let { isPaused } = file
|
||||
|
||||
const socketSend = (action: string, payload?: unknown) => {
|
||||
if (socket == null || socket.readyState !== socket.OPEN) {
|
||||
this.uppy.log(
|
||||
`Cannot send "${action}" to socket ${
|
||||
file.id
|
||||
} because the socket state was ${String(socket?.readyState)}`,
|
||||
'warning',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
action,
|
||||
payload: payload ?? {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function sendState() {
|
||||
if (!capabilities.resumableUploads) return
|
||||
|
||||
if (isPaused) socketSend('pause')
|
||||
else socketSend('resume')
|
||||
}
|
||||
|
||||
const createWebsocket = async () => {
|
||||
if (socketAbortController) socketAbortController.abort()
|
||||
socketAbortController = new AbortController()
|
||||
|
||||
const onFatalError = (err: Error) => {
|
||||
// Remove the serverToken so that a new one will be created for the retry.
|
||||
this.uppy.setFileState(file.id, { serverToken: null })
|
||||
socketAbortController?.abort?.()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
// todo instead implement the ability for users to cancel / retry *currently uploading files* in the UI
|
||||
function resetActivityTimeout() {
|
||||
clearTimeout(activityTimeout)
|
||||
if (isPaused) return
|
||||
activityTimeout = setTimeout(
|
||||
() =>
|
||||
onFatalError(
|
||||
new Error(
|
||||
'Timeout waiting for message from Companion socket',
|
||||
),
|
||||
),
|
||||
socketActivityTimeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await queue
|
||||
.wrapPromiseFunction(async () => {
|
||||
const reconnectWebsocket = async () =>
|
||||
// eslint-disable-next-line promise/param-names
|
||||
new Promise((_, rejectSocket) => {
|
||||
socket = new WebSocket(`${host}/api/${token}`)
|
||||
|
||||
resetActivityTimeout()
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
socket = undefined
|
||||
rejectSocket(new Error('Socket closed unexpectedly'))
|
||||
})
|
||||
|
||||
socket.addEventListener('error', (error) => {
|
||||
this.uppy.log(
|
||||
`Companion socket error ${JSON.stringify(
|
||||
error,
|
||||
)}, closing socket`,
|
||||
'warning',
|
||||
)
|
||||
socket?.close() // will 'close' event to be emitted
|
||||
})
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
sendState()
|
||||
})
|
||||
|
||||
socket.addEventListener('message', (e) => {
|
||||
resetActivityTimeout()
|
||||
|
||||
try {
|
||||
const { action, payload } = JSON.parse(e.data)
|
||||
|
||||
switch (action) {
|
||||
case 'progress': {
|
||||
emitSocketProgress(this, payload, file)
|
||||
break
|
||||
}
|
||||
case 'success': {
|
||||
// @ts-expect-error event expects a lot more data.
|
||||
// TODO: add missing data?
|
||||
this.uppy.emit('upload-success', file, {
|
||||
uploadURL: payload.url,
|
||||
})
|
||||
socketAbortController?.abort?.()
|
||||
resolve()
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
const { message } = payload.error
|
||||
throw Object.assign(new Error(message), {
|
||||
cause: payload.error,
|
||||
})
|
||||
}
|
||||
default:
|
||||
this.uppy.log(
|
||||
`Companion socket unknown action ${action}`,
|
||||
'warning',
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
onFatalError(err)
|
||||
}
|
||||
})
|
||||
|
||||
const closeSocket = () => {
|
||||
this.uppy.log(`Closing socket ${file.id}`, 'info')
|
||||
clearTimeout(activityTimeout)
|
||||
if (socket) socket.close()
|
||||
socket = undefined
|
||||
}
|
||||
|
||||
socketAbortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
closeSocket()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await pRetry(reconnectWebsocket, {
|
||||
retries: retryCount,
|
||||
signal: socketAbortController.signal,
|
||||
onFailedAttempt: () => {
|
||||
if (socketAbortController.signal.aborted) return // don't log in this case
|
||||
this.uppy.log(`Retrying websocket ${file.id}`, 'info')
|
||||
},
|
||||
})
|
||||
})()
|
||||
.abortOn(socketAbortController.signal)
|
||||
} catch (err) {
|
||||
if (socketAbortController.signal.aborted) return
|
||||
onFatalError(err)
|
||||
}
|
||||
}
|
||||
|
||||
const pause = (newPausedState: boolean) => {
|
||||
if (!capabilities.resumableUploads) return
|
||||
|
||||
isPaused = newPausedState
|
||||
if (socket) sendState()
|
||||
|
||||
if (newPausedState) {
|
||||
// Remove this file from the queue so another file can start in its place.
|
||||
socketAbortController?.abort?.() // close socket to free up the request for other uploads
|
||||
} else {
|
||||
// Resuming an upload should be queued, else you could pause and then
|
||||
// resume a queued upload to make it skip the queue.
|
||||
createWebsocket()
|
||||
}
|
||||
}
|
||||
|
||||
const onFileRemove = (targetFile: UppyFile<M, B>) => {
|
||||
if (!capabilities.individualCancellation) return
|
||||
if (targetFile.id !== file.id) return
|
||||
socketSend('cancel')
|
||||
socketAbortController?.abort?.()
|
||||
this.uppy.log(`upload ${file.id} was removed`, 'info')
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onCancelAll = ({ reason }: { reason?: string }) => {
|
||||
if (reason === 'user') {
|
||||
socketSend('cancel')
|
||||
}
|
||||
socketAbortController?.abort?.()
|
||||
this.uppy.log(`upload ${file.id} was canceled`, 'info')
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onFilePausedChange = (
|
||||
targetFileId: string | undefined,
|
||||
newPausedState: boolean,
|
||||
) => {
|
||||
if (targetFileId !== file.id) return
|
||||
pause(newPausedState)
|
||||
}
|
||||
|
||||
const onPauseAll = () => pause(true)
|
||||
const onResumeAll = () => pause(false)
|
||||
|
||||
this.uppy.on('file-removed', onFileRemove)
|
||||
this.uppy.on('cancel-all', onCancelAll)
|
||||
this.uppy.on('upload-pause', onFilePausedChange)
|
||||
this.uppy.on('pause-all', onPauseAll)
|
||||
this.uppy.on('resume-all', onResumeAll)
|
||||
|
||||
removeEventHandlers = () => {
|
||||
this.uppy.off('file-removed', onFileRemove)
|
||||
this.uppy.off('cancel-all', onCancelAll)
|
||||
this.uppy.off('upload-pause', onFilePausedChange)
|
||||
this.uppy.off('pause-all', onPauseAll)
|
||||
this.uppy.off('resume-all', onResumeAll)
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', () => {
|
||||
socketAbortController?.abort()
|
||||
})
|
||||
|
||||
createWebsocket()
|
||||
})
|
||||
} finally {
|
||||
// @ts-expect-error used before defined
|
||||
removeEventHandlers?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
import RequestClient from './RequestClient.js'
|
||||
|
||||
const getName = (id) => {
|
||||
return id.split('-').map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
export default class SearchProvider extends RequestClient {
|
||||
constructor (uppy, opts) {
|
||||
super(uppy, opts)
|
||||
this.provider = opts.provider
|
||||
this.id = this.provider
|
||||
this.name = this.opts.name || getName(this.id)
|
||||
this.pluginId = this.opts.pluginId
|
||||
}
|
||||
|
||||
fileUrl (id) {
|
||||
return `${this.hostname}/search/${this.id}/get/${id}`
|
||||
}
|
||||
|
||||
search (text, queries) {
|
||||
return this.get(`search/${this.id}/list?q=${encodeURIComponent(text)}${queries ? `&${queries}` : ''}`)
|
||||
}
|
||||
}
|
||||
48
packages/@uppy/companion-client/src/SearchProvider.ts
Normal file
48
packages/@uppy/companion-client/src/SearchProvider.ts
Normal 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}` : ''
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
/**
|
||||
* Manages communications with Companion
|
||||
*/
|
||||
|
||||
export { default as RequestClient } from './RequestClient.js'
|
||||
export { default as Provider } from './Provider.js'
|
||||
export { default as SearchProvider } from './SearchProvider.js'
|
||||
|
||||
// TODO: remove in the next major
|
||||
export { default as Socket } from './Socket.js'
|
||||
12
packages/@uppy/companion-client/src/index.ts
Normal file
12
packages/@uppy/companion-client/src/index.ts
Normal 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'
|
||||
|
|
@ -3,18 +3,18 @@
|
|||
/**
|
||||
* This module serves as an Async wrapper for LocalStorage
|
||||
*/
|
||||
export function setItem (key, value) {
|
||||
export function setItem(key: string, value: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
localStorage.setItem(key, value)
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
export function getItem (key) {
|
||||
export function getItem(key: string): Promise<string | null> {
|
||||
return Promise.resolve(localStorage.getItem(key))
|
||||
}
|
||||
|
||||
export function removeItem (key) {
|
||||
export function removeItem(key: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
localStorage.removeItem(key)
|
||||
resolve()
|
||||
25
packages/@uppy/companion-client/tsconfig.build.json
Normal file
25
packages/@uppy/companion-client/tsconfig.build.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
packages/@uppy/companion-client/tsconfig.json
Normal file
21
packages/@uppy/companion-client/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue