@uppy/companion-client
Version:
Client library for communication with Companion. Intended for use in Uppy plugins.
425 lines (424 loc) • 20.1 kB
JavaScript
import ErrorWithCause from '@uppy/utils/lib/ErrorWithCause';
import fetchWithNetworkError from '@uppy/utils/lib/fetchWithNetworkError';
import getSocketHost from '@uppy/utils/lib/getSocketHost';
import UserFacingApiError from '@uppy/utils/lib/UserFacingApiError';
import pRetry, { AbortError } from 'p-retry';
import packageJson from '../package.json' with { type: 'json' };
import AuthError from './AuthError.js';
// 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 });
}
function emitSocketProgress(uploader, progressData, file) {
const { progress, bytesUploaded, bytesTotal } = progressData;
if (progress) {
uploader.uppy.log(`Upload progress: ${progress}`);
uploader.uppy.emit('upload-progress', file, {
uploadStarted: file.progress.uploadStarted ?? 0,
bytesUploaded,
bytesTotal,
});
}
}
export default class RequestClient {
static VERSION = packageJson.version;
#companionHeaders;
uppy;
opts;
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?.[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(res) {
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') },
});
}
}
#getUrl(url) {
if (/^(https?:|)\/\//.test(url)) {
return url;
}
return `${this.hostname}/${url}`;
}
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) {
return this.request({ ...options, path });
}
async post(path, data, options) {
return this.request({ ...options, path, method: 'POST', data });
}
async delete(path, data, 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, 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
*/
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);
let socket;
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 () => {
const reconnectWebsocket = async () => new Promise((_, rejectSocket) => {
socket = new WebSocket(`${host}/api/${token}`);
resetActivityTimeout();
socket.addEventListener('close', () => {
socket = undefined;
rejectSocket(new Error('Socket closed unexpectedly'));
});
socket.addEventListener('error', (error) => {
this.uppy.log(`Companion socket error ${JSON.stringify(error)}, closing socket`, 'warning');
socket?.close(); // will 'close' event to be emitted
});
socket.addEventListener('open', () => {
sendState();
});
socket.addEventListener('message', (e) => {
resetActivityTimeout();
try {
const { action, payload } = JSON.parse(e.data);
switch (action) {
case 'progress': {
emitSocketProgress(this, payload, this.uppy.getFile(file.id));
break;
}
case 'success': {
// payload.response is sent from companion for xhr-upload (aka uploadMultipart in companion) and
// s3 multipart (aka uploadS3Multipart)
// but not for tus/transloadit (aka uploadTus)
// responseText is a string which may or may not be in JSON format
// this means that an upload destination of xhr or s3 multipart MUST respond with valid JSON
// to companion, or the JSON.parse will crash
const text = payload.response?.responseText;
this.uppy.emit('upload-success', this.uppy.getFile(file.id), {
uploadURL: payload.url,
status: payload.response?.status ?? 200,
body: text
? JSON.parse(text)
: undefined,
});
socketAbortController?.abort?.();
resolve();
break;
}
case 'error': {
const { message } = payload.error;
throw Object.assign(new Error(message), {
cause: payload.error,
});
}
default:
this.uppy.log(`Companion socket unknown action ${action}`, 'warning');
}
}
catch (err) {
onFatalError(err);
}
});
const closeSocket = () => {
this.uppy.log(`Closing socket ${file.id}`);
clearTimeout(activityTimeout);
if (socket)
socket.close();
socket = undefined;
};
socketAbortController.signal.addEventListener('abort', () => {
closeSocket();
});
});
await pRetry(reconnectWebsocket, {
retries: retryCount,
signal: socketAbortController.signal,
onFailedAttempt: () => {
if (socketAbortController.signal.aborted)
return; // don't log in this case
this.uppy.log(`Retrying websocket ${file.id}`);
},
});
})()
.abortOn(socketAbortController.signal);
}
catch (err) {
if (socketAbortController.signal.aborted)
return;
onFatalError(err);
}
};
const pause = (newPausedState) => {
if (!capabilities.resumableUploads)
return;
isPaused = newPausedState;
if (socket)
sendState();
};
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`);
resolve();
};
const onCancelAll = () => {
socketSend('cancel');
socketAbortController?.abort?.();
this.uppy.log(`upload ${file.id} was canceled`);
resolve();
};
const onFilePausedChange = (targetFile, newPausedState) => {
if (targetFile?.id !== file.id)
return;
pause(newPausedState);
};
const onPauseAll = () => pause(true);
const onResumeAll = () => pause(false);
this.uppy.on('file-removed', onFileRemove);
this.uppy.on('cancel-all', onCancelAll);
this.uppy.on('upload-pause', onFilePausedChange);
this.uppy.on('pause-all', onPauseAll);
this.uppy.on('resume-all', onResumeAll);
removeEventHandlers = () => {
this.uppy.off('file-removed', onFileRemove);
this.uppy.off('cancel-all', onCancelAll);
this.uppy.off('upload-pause', onFilePausedChange);
this.uppy.off('pause-all', onPauseAll);
this.uppy.off('resume-all', onResumeAll);
};
signal.addEventListener('abort', () => {
socketAbortController?.abort();
});
createWebsocket();
});
}
finally {
// @ts-expect-error used before defined
removeEventHandlers?.();
}
}
}