UNPKG

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