@uppy/xhr-upload
Version:
Plain and simple classic HTML multipart form uploads with Uppy, as well as uploads using the HTTP PUT method.
574 lines (502 loc) • 16 kB
text/typescript
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<M extends Meta, B extends Body>
extends PluginOpts {
endpoint:
| string
| ((
fileOrBundle: UppyFile<M, B> | UppyFile<M, B>[],
) => string | Promise<string>)
method?:
| 'GET'
| 'HEAD'
| 'POST'
| 'PUT'
| 'DELETE'
| 'OPTIONS'
| 'PATCH'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'post'
| 'put'
| string
formData?: boolean
fieldName?: string
headers?:
| Record<string, string>
| ((file: UppyFile<M, B>) => Record<string, string>)
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<M, B>[],
) => void | Promise<void>
shouldRetry?: FetcherOptions['shouldRetry']
onAfterResponse?: FetcherOptions['onAfterResponse']
getResponseData?: (xhr: XMLHttpRequest) => B | Promise<B>
allowedMetaFields?: boolean | string[]
bundle?: boolean
}
export type { XhrUploadOpts as XHRUploadOptions }
declare module '@uppy/utils' {
export interface LocalUppyFile<M extends Meta, B extends Body> {
xhrUpload?: XhrUploadOpts<M, B>
}
export interface RemoteUppyFile<M extends Meta, B extends Body> {
xhrUpload?: XhrUploadOpts<M, B>
}
}
declare module '@uppy/core' {
export interface State<M extends Meta, B extends Body> {
xhrUpload?: XhrUploadOpts<M, B>
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
XHRUpload: XHRUpload<M, B>
}
}
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<M extends Meta, B extends Body>(
file: LocalUppyFile<M, B>,
) {
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<XhrUploadOpts<any, any>>
type Opts<M extends Meta, B extends Body> = DefinePluginOpts<
XhrUploadOpts<M, B>,
keyof typeof defaultOptions
>
interface OptsWithHeaders<M extends Meta, B extends Body> extends Opts<M, B> {
headers: Record<string, string>
}
export default class XHRUpload<
M extends Meta,
B extends Body,
> extends BasePlugin<Opts<M, B>, M, B> {
static VERSION = packageJson.version
#getFetcher
#queue: TaskQueue
uploaderEvents: Record<string, EventManager<M, B> | null>
constructor(uppy: Uppy<M, B>, opts: XhrUploadOpts<M, B>) {
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<M, B>[]) => {
return async (
url: string,
options: Omit<FetcherOptions, 'onBeforeRequest'> & {
onBeforeRequest?: Opts<M, B>['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<M, B>): OptsWithHeaders<M, B> {
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<M, B>['meta'],
opts: Opts<M, B>,
): 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<M, B>, opts: Opts<M, B>): 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<M, B>[],
opts: Opts<M, B>,
): 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<M, B>) {
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<M, B>[]) {
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<M, B>),
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<M, B>) {
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<M, B>[]) {
await Promise.allSettled(
files.map((file) => {
if (file.isRemote) {
const getQueue = () => this.#queue
const controller = new AbortController()
const removedHandler = (removedFile: UppyFile<M, B>) => {
if (removedFile.id === file.id) controller.abort()
}
this.uppy.on('file-removed', removedHandler)
return this.uppy
.getRequestClientForFile<RequestClient<M, B>>(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<M, B>[])
} 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)
}
}