@uppy/xhr-upload
Version:
Plain and simple classic HTML multipart form uploads with Uppy, as well as uploads using the HTTP PUT method.
373 lines (372 loc) • 14.9 kB
JavaScript
import { BasePlugin, EventManager } from '@uppy/core';
import { fetcher, filterFilesToEmitUploadStarted, filterFilesToUpload, getAllowedMetaFields, isNetworkError, NetworkError, TaskQueue, } from '@uppy/utils';
import packageJson from '../package.json' with { type: 'json' };
import locale from './locale.js';
function buildResponseError(xhr, err) {
let error = err;
// No error message
if (!error)
error = new Error('Upload error');
// Got an error message string
if (typeof error === 'string')
error = new Error(error);
// Got something else
if (!(error instanceof Error)) {
error = Object.assign(new Error('Upload error'), { data: error });
}
if (isNetworkError(xhr)) {
error = new NetworkError(error, xhr);
return error;
}
// @ts-expect-error request can only be set on NetworkError
// but we use NetworkError to distinguish between errors.
error.request = xhr;
return error;
}
/**
* Set `data.type` in the blob to `file.meta.type`,
* because we might have detected a more accurate file type in Uppy
* https://stackoverflow.com/a/50875615
*/
function setTypeInBlob(file) {
const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type);
return dataWithUpdatedType;
}
const defaultOptions = {
formData: true,
fieldName: 'file',
method: 'post',
allowedMetaFields: true,
bundle: false,
headers: {},
timeout: 30 * 1000,
limit: 5,
withCredentials: false,
responseType: '',
};
export default class XHRUpload extends BasePlugin {
static VERSION = packageJson.version;
#getFetcher;
#queue;
uploaderEvents;
constructor(uppy, opts) {
super(uppy, {
...defaultOptions,
fieldName: opts.bundle ? 'files[]' : 'file',
...opts,
});
this.type = 'uploader';
this.id = this.opts.id || 'XHRUpload';
this.defaultLocale = locale;
this.i18nInit();
this.#queue = new TaskQueue({ concurrency: this.opts.limit });
if (this.opts.bundle && !this.opts.formData) {
throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.');
}
if (this.opts.bundle && typeof this.opts.headers === 'function') {
throw new Error('`opts.headers` can not be a function when the `bundle: true` option is set.');
}
if (opts?.allowedMetaFields === undefined && 'metaFields' in this.opts) {
throw new Error('The `metaFields` option has been renamed to `allowedMetaFields`.');
}
this.uploaderEvents = Object.create(null);
/**
* xhr-upload wrapper for `fetcher` to handle user options
* `validateStatus`, `getResponseError`, `getResponseData`
* and to emit `upload-progress`, `upload-error`, and `upload-success` events.
*/
this.#getFetcher = (files) => {
return async (url, options) => {
try {
const res = await fetcher(url, {
...options,
onBeforeRequest: (xhr, retryCount) => this.opts.onBeforeRequest?.(xhr, retryCount, files),
shouldRetry: this.opts.shouldRetry,
onAfterResponse: this.opts.onAfterResponse,
onTimeout: (timeout) => {
const seconds = Math.ceil(timeout / 1000);
const error = new Error(this.i18n('uploadStalled', { seconds }));
this.uppy.emit('upload-stalled', error, files);
},
onUploadProgress: (event) => {
if (event.lengthComputable) {
for (const { id } of files) {
const file = this.uppy.getFile(id);
if (file != null) {
this.uppy.emit('upload-progress', file, {
uploadStarted: file.progress.uploadStarted ?? 0,
bytesUploaded: (event.loaded / event.total) * file.size,
bytesTotal: file.size,
});
}
}
}
},
});
let body = await this.opts.getResponseData?.(res);
if (res.responseType === 'json') {
body ??= res.response;
}
else {
try {
body ??= JSON.parse(res.responseText);
}
catch (cause) {
throw new Error('@uppy/xhr-upload expects a JSON response (with a `url` property). To parse non-JSON responses, use `getResponseData` to turn your response into JSON.', { cause });
}
}
const uploadURL = typeof body?.url === 'string' ? body.url : undefined;
for (const { id } of files) {
this.uppy.emit('upload-success', this.uppy.getFile(id), {
status: res.status,
body,
uploadURL,
});
}
return res;
}
catch (error) {
if (error.name === 'AbortError') {
return undefined;
}
const request = error.request;
for (const file of files) {
this.uppy.emit('upload-error', this.uppy.getFile(file.id), buildResponseError(request, error), request);
}
throw error;
}
};
};
}
getOptions(file) {
const overrides = this.uppy.getState().xhrUpload;
const { headers } = this.opts;
const opts = {
...this.opts,
...(overrides || {}),
...(file.xhrUpload || {}),
headers: {},
};
// Support for `headers` as a function, only in the XHRUpload settings.
// Options set by other plugins in Uppy state or on the files themselves are still merged in afterward.
//
// ```js
// headers: (file) => ({ expires: file.meta.expires })
// ```
if (typeof headers === 'function') {
opts.headers = headers(file);
}
else {
Object.assign(opts.headers, this.opts.headers);
}
if (overrides) {
Object.assign(opts.headers, overrides.headers);
}
if (file.xhrUpload) {
Object.assign(opts.headers, file.xhrUpload.headers);
}
return opts;
}
addMetadata(formData, meta, opts) {
const allowedMetaFields = getAllowedMetaFields(opts.allowedMetaFields, meta);
allowedMetaFields.forEach((item) => {
const value = meta[item];
if (Array.isArray(value)) {
// In this case we don't transform `item` to add brackets, it's up to
// the user to add the brackets so it won't be overridden.
value.forEach((subItem) => formData.append(item, subItem));
}
else {
formData.append(item, value);
}
});
}
createFormDataUpload(file, opts) {
const formPost = new FormData();
this.addMetadata(formPost, file.meta, opts);
const dataWithUpdatedType = setTypeInBlob(file);
if (file.name) {
formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name);
}
else {
formPost.append(opts.fieldName, dataWithUpdatedType);
}
return formPost;
}
createBundledUpload(files, opts) {
const formPost = new FormData();
const { meta } = this.uppy.getState();
this.addMetadata(formPost, meta, opts);
files.forEach((file) => {
const options = this.getOptions(file);
const dataWithUpdatedType = setTypeInBlob(file);
if (file.name) {
formPost.append(options.fieldName, dataWithUpdatedType, file.name);
}
else {
formPost.append(options.fieldName, dataWithUpdatedType);
}
});
return formPost;
}
async #uploadLocalFile(file) {
const events = new EventManager(this.uppy);
const controller = new AbortController();
events.onFileRemove(file.id, () => controller.abort());
events.onCancelAll(file.id, () => controller.abort());
try {
await this.#queue.add(async (signal) => {
const opts = this.getOptions(file);
const fetch = this.#getFetcher([file]);
const body = opts.formData
? this.createFormDataUpload(file, opts)
: file.data;
const endpoint = typeof opts.endpoint === 'string'
? opts.endpoint
: await opts.endpoint(file);
return fetch(endpoint, {
...opts,
body,
signal: AbortSignal.any([signal, controller.signal]),
});
});
}
catch (error) {
if (error.name === 'AbortError') {
return;
}
throw error;
}
finally {
events.remove();
}
}
async #uploadBundle(files) {
const controller = new AbortController();
function abort() {
controller.abort();
}
// We only need to abort on cancel all because
// individual cancellations are not possible with bundle: true
this.uppy.once('cancel-all', abort);
try {
await this.#queue.add(async (signal) => {
const optsFromState = this.uppy.getState().xhrUpload ?? {};
const fetch = this.#getFetcher(files);
const body = this.createBundledUpload(files, {
...this.opts,
...optsFromState,
});
const endpoint = typeof this.opts.endpoint === 'string'
? this.opts.endpoint
: await this.opts.endpoint(files);
return fetch(endpoint, {
// headers can't be a function with bundle: true
...this.opts,
body,
signal: AbortSignal.any([signal, controller.signal]),
});
});
}
catch (error) {
if (error.name === 'AbortError') {
return;
}
throw error;
}
finally {
this.uppy.off('cancel-all', abort);
}
}
#getCompanionClientArgs(file) {
const opts = this.getOptions(file);
const allowedMetaFields = getAllowedMetaFields(opts.allowedMetaFields, file.meta);
return {
...file.remote?.body,
protocol: 'multipart',
endpoint: opts.endpoint,
size: file.data.size,
fieldname: opts.fieldName,
metadata: Object.fromEntries(allowedMetaFields.map((name) => [name, file.meta[name]])),
httpMethod: opts.method,
useFormData: opts.formData,
headers: opts.headers,
};
}
async #uploadFiles(files) {
await Promise.allSettled(files.map((file) => {
if (file.isRemote) {
const getQueue = () => this.#queue;
const controller = new AbortController();
const removedHandler = (removedFile) => {
if (removedFile.id === file.id)
controller.abort();
};
this.uppy.on('file-removed', removedHandler);
return this.uppy
.getRequestClientForFile(file)
.uploadRemoteFile(file, this.#getCompanionClientArgs(file), {
signal: controller.signal,
getQueue,
})
.finally(() => {
this.uppy.off('file-removed', removedHandler);
});
}
return this.#uploadLocalFile(file);
}));
}
#handleUpload = async (fileIDs) => {
if (fileIDs.length === 0) {
this.uppy.log('[XHRUpload] No files to upload!');
return;
}
// No limit configured by the user
if (this.opts.limit === 0) {
this.uppy.log('[XHRUpload] When uploading multiple files at once, consider setting the `limit` option (to `10` for example), to limit the number of concurrent uploads, which helps prevent memory and network issues: https://uppy.io/docs/xhr-upload/#limit-0', 'warning');
}
this.uppy.log('[XHRUpload] Uploading...');
const files = this.uppy.getFilesByIds(fileIDs);
const filesFiltered = filterFilesToUpload(files);
const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered);
this.uppy.emit('upload-start', filesToEmit);
if (this.opts.bundle) {
// if bundle: true, we don’t support remote uploads
const isSomeFileRemote = filesFiltered.some((file) => file.isRemote);
if (isSomeFileRemote) {
throw new Error('Can’t upload remote files when the `bundle: true` option is set');
}
if (typeof this.opts.headers === 'function') {
throw new TypeError('`headers` may not be a function when the `bundle: true` option is set');
}
await this.#uploadBundle(filesFiltered);
}
else {
await this.#uploadFiles(filesFiltered);
}
};
install() {
if (this.opts.bundle) {
const { capabilities } = this.uppy.getState();
this.uppy.setState({
capabilities: {
...capabilities,
individualCancellation: false,
},
});
}
this.uppy.addUploader(this.#handleUpload);
}
uninstall() {
if (this.opts.bundle) {
const { capabilities } = this.uppy.getState();
this.uppy.setState({
capabilities: {
...capabilities,
individualCancellation: true,
},
});
}
this.uppy.removeUploader(this.#handleUpload);
}
}