import type { RequestClient } from '@uppy/companion-client' import type { Body, DefinePluginOpts, Meta, PluginOpts, State, Uppy, UppyFile, } from '@uppy/core' import { BasePlugin, EventManager } from '@uppy/core' import { type FetcherOptions, fetcher, filterFilesToEmitUploadStarted, filterFilesToUpload, getAllowedMetaFields, isNetworkError, type LocalUppyFile, NetworkError, type RemoteUppyFile, TaskQueue, } from '@uppy/utils' import packageJson from '../package.json' with { type: 'json' } import locale from './locale.js' export interface XhrUploadOpts extends PluginOpts { endpoint: | string | (( fileOrBundle: UppyFile | UppyFile[], ) => string | Promise) method?: | 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS' | 'PATCH' | 'delete' | 'get' | 'head' | 'options' | 'post' | 'put' | string formData?: boolean fieldName?: string headers?: | Record | ((file: UppyFile) => Record) timeout?: number limit?: number responseType?: XMLHttpRequestResponseType withCredentials?: boolean onBeforeRequest?: ( xhr: XMLHttpRequest, retryCount: number, /** The files to be uploaded. When `bundle` is `false` only one file is in the array. */ files: UppyFile[], ) => void | Promise shouldRetry?: FetcherOptions['shouldRetry'] onAfterResponse?: FetcherOptions['onAfterResponse'] getResponseData?: (xhr: XMLHttpRequest) => B | Promise allowedMetaFields?: boolean | string[] bundle?: boolean } export type { XhrUploadOpts as XHRUploadOptions } declare module '@uppy/utils' { export interface LocalUppyFile { xhrUpload?: XhrUploadOpts } export interface RemoteUppyFile { xhrUpload?: XhrUploadOpts } } declare module '@uppy/core' { export interface State { xhrUpload?: XhrUploadOpts } } declare module '@uppy/core' { export interface PluginTypeRegistry { XHRUpload: XHRUpload } } function buildResponseError( xhr?: XMLHttpRequest, err?: string | Error | NetworkError, ) { 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: LocalUppyFile, ) { 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: '', } satisfies Partial> type Opts = DefinePluginOpts< XhrUploadOpts, keyof typeof defaultOptions > interface OptsWithHeaders extends Opts { headers: Record } export default class XHRUpload< M extends Meta, B extends Body, > extends BasePlugin, M, B> { static VERSION = packageJson.version #getFetcher #queue: TaskQueue uploaderEvents: Record | null> constructor(uppy: Uppy, opts: XhrUploadOpts) { 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: UppyFile[]) => { return async ( url: string, options: Omit & { onBeforeRequest?: Opts['onBeforeRequest'] }, ) => { 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) as B } 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 as XMLHttpRequest | undefined for (const file of files) { this.uppy.emit( 'upload-error', this.uppy.getFile(file.id), buildResponseError(request, error), request, ) } throw error } } } } getOptions(file: UppyFile): OptsWithHeaders { 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: FormData, meta: State['meta'], opts: Opts, ): void { 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 as string) } }) } createFormDataUpload(file: LocalUppyFile, opts: Opts): FormData { 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: LocalUppyFile[], opts: Opts, ): FormData { 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: LocalUppyFile) { 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: LocalUppyFile[]) { 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 as OptsWithHeaders), 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: RemoteUppyFile) { 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: UppyFile[]) { await Promise.allSettled( files.map((file) => { if (file.isRemote) { const getQueue = () => this.#queue const controller = new AbortController() const removedHandler = (removedFile: UppyFile) => { 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: string[]) => { 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 as LocalUppyFile[]) } else { await this.#uploadFiles(filesFiltered) } } install(): void { if (this.opts.bundle) { const { capabilities } = this.uppy.getState() this.uppy.setState({ capabilities: { ...capabilities, individualCancellation: false, }, }) } this.uppy.addUploader(this.#handleUpload) } uninstall(): void { if (this.opts.bundle) { const { capabilities } = this.uppy.getState() this.uppy.setState({ capabilities: { ...capabilities, individualCancellation: true, }, }) } this.uppy.removeUploader(this.#handleUpload) } }