123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700 |
- import BasePlugin from '@uppy/core/lib/BasePlugin.js'
- import { nanoid } from 'nanoid/non-secure'
- import { Provider, RequestClient, Socket } from '@uppy/companion-client'
- import emitSocketProgress from '@uppy/utils/lib/emitSocketProgress'
- import getSocketHost from '@uppy/utils/lib/getSocketHost'
- import settle from '@uppy/utils/lib/settle'
- import EventTracker from '@uppy/utils/lib/EventTracker'
- import ProgressTimeout from '@uppy/utils/lib/ProgressTimeout'
- import { RateLimitedQueue, internalRateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
- import NetworkError from '@uppy/utils/lib/NetworkError'
- import isNetworkError from '@uppy/utils/lib/isNetworkError'
- import packageJson from '../package.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
- }
- 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
- *
- * @param {object} file File object with `data`, `size` and `meta` properties
- * @returns {object} blob updated with the new `type` set from `file.meta.type`
- */
- function setTypeInBlob (file) {
- const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type)
- return dataWithUpdatedType
- }
- export default class XHRUpload extends BasePlugin {
- // eslint-disable-next-line global-require
- static VERSION = packageJson.version
- #queueRequestSocketToken
- constructor (uppy, opts) {
- super(uppy, opts)
- this.type = 'uploader'
- this.id = this.opts.id || 'XHRUpload'
- this.title = 'XHRUpload'
- this.defaultLocale = locale
- // Default options
- const defaultOptions = {
- formData: true,
- fieldName: opts.bundle ? 'files[]' : 'file',
- method: 'post',
- allowedMetaFields: null,
- responseUrlFieldName: 'url',
- bundle: false,
- headers: {},
- timeout: 30 * 1000,
- limit: 5,
- withCredentials: false,
- responseType: '',
- /**
- * @param {string} responseText the response body string
- */
- getResponseData (responseText) {
- let parsedResponse = {}
- try {
- parsedResponse = JSON.parse(responseText)
- } catch (err) {
- uppy.log(err)
- }
- return parsedResponse
- },
- /**
- *
- * @param {string} _ the response body string
- * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
- */
- getResponseError (_, response) {
- let error = new Error('Upload error')
- if (isNetworkError(response)) {
- error = new NetworkError(error, response)
- }
- return error
- },
- /**
- * Check if the response from the upload endpoint indicates that the upload was successful.
- *
- * @param {number} status the response status code
- */
- validateStatus (status) {
- return status >= 200 && status < 300
- },
- }
- this.opts = { ...defaultOptions, ...opts }
- this.i18nInit()
- this.handleUpload = this.handleUpload.bind(this)
- // Simultaneous upload limiting is shared across all uploads with this plugin.
- if (internalRateLimitedQueue in this.opts) {
- this.requests = this.opts[internalRateLimitedQueue]
- } else {
- this.requests = new RateLimitedQueue(this.opts.limit)
- }
- if (this.opts.bundle && !this.opts.formData) {
- throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.')
- }
- if (opts?.allowedMetaFields === undefined && 'metaFields' in this.opts) {
- throw new Error('The `metaFields` option has been renamed to `allowedMetaFields`.')
- }
- this.uploaderEvents = Object.create(null)
- this.#queueRequestSocketToken = this.requests.wrapPromiseFunction(this.#requestSocketToken, { priority: -1 })
- }
- 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
- }
- // eslint-disable-next-line class-methods-use-this
- addMetadata (formData, meta, opts) {
- const allowedMetaFields = Array.isArray(opts.allowedMetaFields)
- ? opts.allowedMetaFields
- : Object.keys(meta) // Send along all fields by default.
- allowedMetaFields.forEach((item) => {
- formData.append(item, meta[item])
- })
- }
- 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
- }
- upload (file, current, total) {
- const opts = this.getOptions(file)
- this.uppy.log(`uploading ${current} of ${total}`)
- return new Promise((resolve, reject) => {
- this.uppy.emit('upload-started', file)
- const data = opts.formData
- ? this.createFormDataUpload(file, opts)
- : file.data
- const xhr = new XMLHttpRequest()
- this.uploaderEvents[file.id] = new EventTracker(this.uppy)
- let queuedRequest
- const timer = new ProgressTimeout(opts.timeout, () => {
- const error = new Error(this.i18n('uploadStalled', { seconds: Math.ceil(opts.timeout / 1000) }))
- this.uppy.emit('upload-stalled', error, [file])
- })
- const id = nanoid()
- xhr.upload.addEventListener('loadstart', () => {
- this.uppy.log(`[XHRUpload] ${id} started`)
- })
- xhr.upload.addEventListener('progress', (ev) => {
- this.uppy.log(`[XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`)
- // Begin checking for timeouts when progress starts, instead of loading,
- // to avoid timing out requests on browser concurrency queue
- timer.progress()
- if (ev.lengthComputable) {
- this.uppy.emit('upload-progress', file, {
- uploader: this,
- bytesUploaded: ev.loaded,
- bytesTotal: ev.total,
- })
- }
- })
- xhr.addEventListener('load', () => {
- this.uppy.log(`[XHRUpload] ${id} finished`)
- timer.done()
- queuedRequest.done()
- if (this.uploaderEvents[file.id]) {
- this.uploaderEvents[file.id].remove()
- this.uploaderEvents[file.id] = null
- }
- if (opts.validateStatus(xhr.status, xhr.responseText, xhr)) {
- const body = opts.getResponseData(xhr.responseText, xhr)
- const uploadURL = body[opts.responseUrlFieldName]
- const uploadResp = {
- status: xhr.status,
- body,
- uploadURL,
- }
- this.uppy.emit('upload-success', file, uploadResp)
- if (uploadURL) {
- this.uppy.log(`Download ${file.name} from ${uploadURL}`)
- }
- return resolve(file)
- }
- const body = opts.getResponseData(xhr.responseText, xhr)
- const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
- const response = {
- status: xhr.status,
- body,
- }
- this.uppy.emit('upload-error', file, error, response)
- return reject(error)
- })
- xhr.addEventListener('error', () => {
- this.uppy.log(`[XHRUpload] ${id} errored`)
- timer.done()
- queuedRequest.done()
- if (this.uploaderEvents[file.id]) {
- this.uploaderEvents[file.id].remove()
- this.uploaderEvents[file.id] = null
- }
- const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
- this.uppy.emit('upload-error', file, error)
- return reject(error)
- })
- xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
- // IE10 does not allow setting `withCredentials` and `responseType`
- // before `open()` is called.
- xhr.withCredentials = opts.withCredentials
- if (opts.responseType !== '') {
- xhr.responseType = opts.responseType
- }
- queuedRequest = this.requests.run(() => {
- this.uppy.emit('upload-started', file)
- // When using an authentication system like JWT, the bearer token goes as a header. This
- // header needs to be fresh each time the token is refreshed so computing and setting the
- // headers just before the upload starts enables this kind of authentication to work properly.
- // Otherwise, half-way through the list of uploads the token could be stale and the upload would fail.
- const currentOpts = this.getOptions(file)
- Object.keys(currentOpts.headers).forEach((header) => {
- xhr.setRequestHeader(header, currentOpts.headers[header])
- })
- xhr.send(data)
- return () => {
- timer.done()
- xhr.abort()
- }
- })
- this.onFileRemove(file.id, () => {
- queuedRequest.abort()
- reject(new Error('File removed'))
- })
- this.onCancelAll(file.id, ({ reason }) => {
- if (reason === 'user') {
- queuedRequest.abort()
- }
- reject(new Error('Upload cancelled'))
- })
- })
- }
- #requestSocketToken = async (file) => {
- const opts = this.getOptions(file)
- const Client = file.remote.providerOptions.provider ? Provider : RequestClient
- const client = new Client(this.uppy, file.remote.providerOptions)
- const allowedMetaFields = Array.isArray(opts.allowedMetaFields)
- ? opts.allowedMetaFields
- // Send along all fields by default.
- : Object.keys(file.meta)
- const res = await client.post(file.remote.url, {
- ...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,
- })
- return res.token
- }
- async uploadRemote (file) {
- // TODO: we could rewrite this to use server-sent events instead of creating WebSockets.
- try {
- this.uppy.emit('upload-started', file)
- if (file.serverToken) {
- return this.connectToServerSocket(file)
- }
- const serverToken = await this.#queueRequestSocketToken(file)
- if (!this.uppy.getState().files[file.id]) return undefined
- this.uppy.setFileState(file.id, { serverToken })
- return this.connectToServerSocket(this.uppy.getFile(file.id))
- } catch (err) {
- this.uppy.emit('upload-error', file, err)
- throw err
- }
- }
- connectToServerSocket (file) {
- return new Promise((resolve, reject) => {
- const opts = this.getOptions(file)
- const token = file.serverToken
- const host = getSocketHost(file.remote.companionUrl)
- let socket
- const createSocket = () => {
- if (socket != null) return
- socket = new Socket({ target: `${host}/api/${token}` })
- socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
- socket.on('success', (data) => {
- const body = opts.getResponseData(data.response.responseText, data.response)
- const uploadURL = body[opts.responseUrlFieldName]
- const uploadResp = {
- status: data.response.status,
- body,
- uploadURL,
- }
- this.uppy.emit('upload-success', file, uploadResp)
- queuedRequest.done() // eslint-disable-line no-use-before-define
- socket.close()
- if (this.uploaderEvents[file.id]) {
- this.uploaderEvents[file.id].remove()
- this.uploaderEvents[file.id] = null
- }
- return resolve()
- })
- socket.on('error', (errData) => {
- const resp = errData.response
- const error = resp
- ? opts.getResponseError(resp.responseText, resp)
- : Object.assign(new Error(errData.error.message), { cause: errData.error })
- this.uppy.emit('upload-error', file, error)
- queuedRequest.done() // eslint-disable-line no-use-before-define
- if (this.uploaderEvents[file.id]) {
- this.uploaderEvents[file.id].remove()
- this.uploaderEvents[file.id] = null
- }
- reject(error)
- })
- }
- this.uploaderEvents[file.id] = new EventTracker(this.uppy)
- let queuedRequest = this.requests.run(() => {
- if (file.isPaused) {
- socket?.send('pause', {})
- } else {
- createSocket()
- }
- return () => socket.close()
- })
- this.onFileRemove(file.id, () => {
- socket?.send('cancel', {})
- queuedRequest.abort()
- resolve(`upload ${file.id} was removed`)
- })
- this.onCancelAll(file.id, ({ reason } = {}) => {
- if (reason === 'user') {
- socket?.send('cancel', {})
- queuedRequest.abort()
- }
- resolve(`upload ${file.id} was canceled`)
- })
- const onRetryRequest = () => {
- if (socket == null) {
- queuedRequest.abort()
- } else {
- socket.send('pause', {})
- queuedRequest.done()
- }
- queuedRequest = this.requests.run(() => {
- if (!file.isPaused) {
- if (socket == null) {
- createSocket()
- } else {
- socket.send('resume', {})
- }
- }
- return () => socket.close()
- })
- }
- this.onRetry(file.id, onRetryRequest)
- this.onRetryAll(file.id, onRetryRequest)
- }).catch((err) => {
- this.uppy.emit('upload-error', file, err)
- return Promise.reject(err)
- })
- }
- uploadBundle (files) {
- return new Promise((resolve, reject) => {
- const { endpoint } = this.opts
- const { method } = this.opts
- const optsFromState = this.uppy.getState().xhrUpload
- const formData = this.createBundledUpload(files, {
- ...this.opts,
- ...(optsFromState || {}),
- })
- const xhr = new XMLHttpRequest()
- const emitError = (error) => {
- files.forEach((file) => {
- this.uppy.emit('upload-error', file, error)
- })
- }
- const timer = new ProgressTimeout(this.opts.timeout, () => {
- const error = new Error(this.i18n('uploadStalled', { seconds: Math.ceil(this.opts.timeout / 1000) }))
- this.uppy.emit('upload-stalled', error, files)
- })
- xhr.upload.addEventListener('loadstart', () => {
- this.uppy.log('[XHRUpload] started uploading bundle')
- timer.progress()
- })
- xhr.upload.addEventListener('progress', (ev) => {
- timer.progress()
- if (!ev.lengthComputable) return
- files.forEach((file) => {
- this.uppy.emit('upload-progress', file, {
- uploader: this,
- bytesUploaded: (ev.loaded / ev.total) * file.size,
- bytesTotal: file.size,
- })
- })
- })
- xhr.addEventListener('load', (ev) => {
- timer.done()
- if (this.opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
- const body = this.opts.getResponseData(xhr.responseText, xhr)
- const uploadResp = {
- status: ev.target.status,
- body,
- }
- files.forEach((file) => {
- this.uppy.emit('upload-success', file, uploadResp)
- })
- return resolve()
- }
- const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
- error.request = xhr
- emitError(error)
- return reject(error)
- })
- xhr.addEventListener('error', () => {
- timer.done()
- const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
- emitError(error)
- return reject(error)
- })
- this.uppy.on('cancel-all', ({ reason } = {}) => {
- if (reason !== 'user') return
- timer.done()
- xhr.abort()
- })
- xhr.open(method.toUpperCase(), endpoint, true)
- // IE10 does not allow setting `withCredentials` and `responseType`
- // before `open()` is called.
- xhr.withCredentials = this.opts.withCredentials
- if (this.opts.responseType !== '') {
- xhr.responseType = this.opts.responseType
- }
- Object.keys(this.opts.headers).forEach((header) => {
- xhr.setRequestHeader(header, this.opts.headers[header])
- })
- xhr.send(formData)
- files.forEach((file) => {
- this.uppy.emit('upload-started', file)
- })
- })
- }
- uploadFiles (files) {
- const promises = files.map((file, i) => {
- const current = parseInt(i, 10) + 1
- const total = files.length
- if (file.error) {
- return Promise.reject(new Error(file.error))
- } if (file.isRemote) {
- return this.uploadRemote(file, current, total)
- }
- return this.upload(file, current, total)
- })
- return settle(promises)
- }
- onFileRemove (fileID, cb) {
- this.uploaderEvents[fileID].on('file-removed', (file) => {
- if (fileID === file.id) cb(file.id)
- })
- }
- onRetry (fileID, cb) {
- this.uploaderEvents[fileID].on('upload-retry', (targetFileID) => {
- if (fileID === targetFileID) {
- cb()
- }
- })
- }
- onRetryAll (fileID, cb) {
- this.uploaderEvents[fileID].on('retry-all', () => {
- if (!this.uppy.getFile(fileID)) return
- cb()
- })
- }
- onCancelAll (fileID, eventHandler) {
- this.uploaderEvents[fileID].on('cancel-all', (...args) => {
- if (!this.uppy.getFile(fileID)) return
- eventHandler(...args)
- })
- }
- handleUpload (fileIDs) {
- if (fileIDs.length === 0) {
- this.uppy.log('[XHRUpload] No files to upload!')
- return Promise.resolve()
- }
- // No limit configured by the user, and no RateLimitedQueue passed in by a "parent" plugin
- // (basically just AwsS3) using the internal symbol
- if (this.opts.limit === 0 && !this.opts[internalRateLimitedQueue]) {
- 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 = fileIDs.map((fileID) => this.uppy.getFile(fileID))
- if (this.opts.bundle) {
- // if bundle: true, we don’t support remote uploads
- const isSomeFileRemote = files.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')
- }
- return this.uploadBundle(files)
- }
- return this.uploadFiles(files).then(() => null)
- }
- 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)
- }
- }
|