index.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import BasePlugin from '@uppy/core/lib/BasePlugin.js'
  2. import { nanoid } from 'nanoid/non-secure'
  3. import EventManager from '@uppy/utils/lib/EventManager'
  4. import ProgressTimeout from '@uppy/utils/lib/ProgressTimeout'
  5. import { RateLimitedQueue, internalRateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
  6. import NetworkError from '@uppy/utils/lib/NetworkError'
  7. import isNetworkError from '@uppy/utils/lib/isNetworkError'
  8. import { filterNonFailedFiles, filterFilesToEmitUploadStarted } from '@uppy/utils/lib/fileFilters'
  9. import packageJson from '../package.json'
  10. import locale from './locale.js'
  11. function buildResponseError (xhr, err) {
  12. let error = err
  13. // No error message
  14. if (!error) error = new Error('Upload error')
  15. // Got an error message string
  16. if (typeof error === 'string') error = new Error(error)
  17. // Got something else
  18. if (!(error instanceof Error)) {
  19. error = Object.assign(new Error('Upload error'), { data: error })
  20. }
  21. if (isNetworkError(xhr)) {
  22. error = new NetworkError(error, xhr)
  23. return error
  24. }
  25. error.request = xhr
  26. return error
  27. }
  28. /**
  29. * Set `data.type` in the blob to `file.meta.type`,
  30. * because we might have detected a more accurate file type in Uppy
  31. * https://stackoverflow.com/a/50875615
  32. *
  33. * @param {object} file File object with `data`, `size` and `meta` properties
  34. * @returns {object} blob updated with the new `type` set from `file.meta.type`
  35. */
  36. function setTypeInBlob (file) {
  37. const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type)
  38. return dataWithUpdatedType
  39. }
  40. export default class XHRUpload extends BasePlugin {
  41. // eslint-disable-next-line global-require
  42. static VERSION = packageJson.version
  43. constructor (uppy, opts) {
  44. super(uppy, opts)
  45. this.type = 'uploader'
  46. this.id = this.opts.id || 'XHRUpload'
  47. this.title = 'XHRUpload'
  48. this.defaultLocale = locale
  49. // Default options
  50. const defaultOptions = {
  51. formData: true,
  52. fieldName: opts.bundle ? 'files[]' : 'file',
  53. method: 'post',
  54. allowedMetaFields: null,
  55. responseUrlFieldName: 'url',
  56. bundle: false,
  57. headers: {},
  58. timeout: 30 * 1000,
  59. limit: 5,
  60. withCredentials: false,
  61. responseType: '',
  62. /**
  63. * @param {string} responseText the response body string
  64. */
  65. getResponseData (responseText) {
  66. let parsedResponse = {}
  67. try {
  68. parsedResponse = JSON.parse(responseText)
  69. } catch (err) {
  70. uppy.log(err)
  71. }
  72. return parsedResponse
  73. },
  74. /**
  75. *
  76. * @param {string} _ the response body string
  77. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  78. */
  79. getResponseError (_, response) {
  80. let error = new Error('Upload error')
  81. if (isNetworkError(response)) {
  82. error = new NetworkError(error, response)
  83. }
  84. return error
  85. },
  86. /**
  87. * Check if the response from the upload endpoint indicates that the upload was successful.
  88. *
  89. * @param {number} status the response status code
  90. */
  91. validateStatus (status) {
  92. return status >= 200 && status < 300
  93. },
  94. }
  95. this.opts = { ...defaultOptions, ...opts }
  96. this.i18nInit()
  97. // Simultaneous upload limiting is shared across all uploads with this plugin.
  98. if (internalRateLimitedQueue in this.opts) {
  99. this.requests = this.opts[internalRateLimitedQueue]
  100. } else {
  101. this.requests = new RateLimitedQueue(this.opts.limit)
  102. }
  103. if (this.opts.bundle && !this.opts.formData) {
  104. throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.')
  105. }
  106. if (opts?.allowedMetaFields === undefined && 'metaFields' in this.opts) {
  107. throw new Error('The `metaFields` option has been renamed to `allowedMetaFields`.')
  108. }
  109. this.uploaderEvents = Object.create(null)
  110. }
  111. getOptions (file) {
  112. const overrides = this.uppy.getState().xhrUpload
  113. const { headers } = this.opts
  114. const opts = {
  115. ...this.opts,
  116. ...(overrides || {}),
  117. ...(file.xhrUpload || {}),
  118. headers: {},
  119. }
  120. // Support for `headers` as a function, only in the XHRUpload settings.
  121. // Options set by other plugins in Uppy state or on the files themselves are still merged in afterward.
  122. //
  123. // ```js
  124. // headers: (file) => ({ expires: file.meta.expires })
  125. // ```
  126. if (typeof headers === 'function') {
  127. opts.headers = headers(file)
  128. } else {
  129. Object.assign(opts.headers, this.opts.headers)
  130. }
  131. if (overrides) {
  132. Object.assign(opts.headers, overrides.headers)
  133. }
  134. if (file.xhrUpload) {
  135. Object.assign(opts.headers, file.xhrUpload.headers)
  136. }
  137. return opts
  138. }
  139. // eslint-disable-next-line class-methods-use-this
  140. addMetadata (formData, meta, opts) {
  141. const allowedMetaFields = Array.isArray(opts.allowedMetaFields)
  142. ? opts.allowedMetaFields
  143. : Object.keys(meta) // Send along all fields by default.
  144. allowedMetaFields.forEach((item) => {
  145. if (Array.isArray(meta[item])) {
  146. // In this case we don't transform `item` to add brackets, it's up to
  147. // the user to add the brackets so it won't be overridden.
  148. meta[item].forEach(subItem => formData.append(item, subItem))
  149. } else {
  150. formData.append(item, meta[item])
  151. }
  152. })
  153. }
  154. createFormDataUpload (file, opts) {
  155. const formPost = new FormData()
  156. this.addMetadata(formPost, file.meta, opts)
  157. const dataWithUpdatedType = setTypeInBlob(file)
  158. if (file.name) {
  159. formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name)
  160. } else {
  161. formPost.append(opts.fieldName, dataWithUpdatedType)
  162. }
  163. return formPost
  164. }
  165. createBundledUpload (files, opts) {
  166. const formPost = new FormData()
  167. const { meta } = this.uppy.getState()
  168. this.addMetadata(formPost, meta, opts)
  169. files.forEach((file) => {
  170. const options = this.getOptions(file)
  171. const dataWithUpdatedType = setTypeInBlob(file)
  172. if (file.name) {
  173. formPost.append(options.fieldName, dataWithUpdatedType, file.name)
  174. } else {
  175. formPost.append(options.fieldName, dataWithUpdatedType)
  176. }
  177. })
  178. return formPost
  179. }
  180. async #uploadLocalFile (file, current, total) {
  181. const opts = this.getOptions(file)
  182. this.uppy.log(`uploading ${current} of ${total}`)
  183. return new Promise((resolve, reject) => {
  184. const data = opts.formData
  185. ? this.createFormDataUpload(file, opts)
  186. : file.data
  187. const xhr = new XMLHttpRequest()
  188. const eventManager = new EventManager(this.uppy)
  189. this.uploaderEvents[file.id] = eventManager
  190. let queuedRequest
  191. const timer = new ProgressTimeout(opts.timeout, () => {
  192. const error = new Error(this.i18n('uploadStalled', { seconds: Math.ceil(opts.timeout / 1000) }))
  193. this.uppy.emit('upload-stalled', error, [file])
  194. })
  195. const id = nanoid()
  196. xhr.upload.addEventListener('loadstart', () => {
  197. this.uppy.log(`[XHRUpload] ${id} started`)
  198. })
  199. xhr.upload.addEventListener('progress', (ev) => {
  200. this.uppy.log(`[XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`)
  201. // Begin checking for timeouts when progress starts, instead of loading,
  202. // to avoid timing out requests on browser concurrency queue
  203. timer.progress()
  204. if (ev.lengthComputable) {
  205. this.uppy.emit('upload-progress', file, {
  206. uploader: this,
  207. bytesUploaded: ev.loaded,
  208. bytesTotal: ev.total,
  209. })
  210. }
  211. })
  212. xhr.addEventListener('load', () => {
  213. this.uppy.log(`[XHRUpload] ${id} finished`)
  214. timer.done()
  215. queuedRequest.done()
  216. if (this.uploaderEvents[file.id]) {
  217. this.uploaderEvents[file.id].remove()
  218. this.uploaderEvents[file.id] = null
  219. }
  220. if (opts.validateStatus(xhr.status, xhr.responseText, xhr)) {
  221. const body = opts.getResponseData(xhr.responseText, xhr)
  222. const uploadURL = body[opts.responseUrlFieldName]
  223. const uploadResp = {
  224. status: xhr.status,
  225. body,
  226. uploadURL,
  227. }
  228. this.uppy.emit('upload-success', file, uploadResp)
  229. if (uploadURL) {
  230. this.uppy.log(`Download ${file.name} from ${uploadURL}`)
  231. }
  232. return resolve(file)
  233. }
  234. const body = opts.getResponseData(xhr.responseText, xhr)
  235. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  236. const response = {
  237. status: xhr.status,
  238. body,
  239. }
  240. this.uppy.emit('upload-error', file, error, response)
  241. return reject(error)
  242. })
  243. xhr.addEventListener('error', () => {
  244. this.uppy.log(`[XHRUpload] ${id} errored`)
  245. timer.done()
  246. queuedRequest.done()
  247. if (this.uploaderEvents[file.id]) {
  248. this.uploaderEvents[file.id].remove()
  249. this.uploaderEvents[file.id] = null
  250. }
  251. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  252. this.uppy.emit('upload-error', file, error)
  253. return reject(error)
  254. })
  255. xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
  256. // IE10 does not allow setting `withCredentials` and `responseType`
  257. // before `open()` is called.
  258. xhr.withCredentials = opts.withCredentials
  259. if (opts.responseType !== '') {
  260. xhr.responseType = opts.responseType
  261. }
  262. queuedRequest = this.requests.run(() => {
  263. // When using an authentication system like JWT, the bearer token goes as a header. This
  264. // header needs to be fresh each time the token is refreshed so computing and setting the
  265. // headers just before the upload starts enables this kind of authentication to work properly.
  266. // Otherwise, half-way through the list of uploads the token could be stale and the upload would fail.
  267. const currentOpts = this.getOptions(file)
  268. Object.keys(currentOpts.headers).forEach((header) => {
  269. xhr.setRequestHeader(header, currentOpts.headers[header])
  270. })
  271. xhr.send(data)
  272. return () => {
  273. timer.done()
  274. xhr.abort()
  275. }
  276. })
  277. eventManager.onFileRemove(file.id, () => {
  278. queuedRequest.abort()
  279. reject(new Error('File removed'))
  280. })
  281. eventManager.onCancelAll(file.id, ({ reason }) => {
  282. if (reason === 'user') {
  283. queuedRequest.abort()
  284. }
  285. reject(new Error('Upload cancelled'))
  286. })
  287. })
  288. }
  289. #uploadBundle (files) {
  290. return new Promise((resolve, reject) => {
  291. const { endpoint } = this.opts
  292. const { method } = this.opts
  293. const optsFromState = this.uppy.getState().xhrUpload
  294. const formData = this.createBundledUpload(files, {
  295. ...this.opts,
  296. ...(optsFromState || {}),
  297. })
  298. const xhr = new XMLHttpRequest()
  299. const emitError = (error) => {
  300. files.forEach((file) => {
  301. this.uppy.emit('upload-error', file, error)
  302. })
  303. }
  304. const timer = new ProgressTimeout(this.opts.timeout, () => {
  305. const error = new Error(this.i18n('uploadStalled', { seconds: Math.ceil(this.opts.timeout / 1000) }))
  306. this.uppy.emit('upload-stalled', error, files)
  307. })
  308. xhr.upload.addEventListener('loadstart', () => {
  309. this.uppy.log('[XHRUpload] started uploading bundle')
  310. timer.progress()
  311. })
  312. xhr.upload.addEventListener('progress', (ev) => {
  313. timer.progress()
  314. if (!ev.lengthComputable) return
  315. files.forEach((file) => {
  316. this.uppy.emit('upload-progress', file, {
  317. uploader: this,
  318. bytesUploaded: (ev.loaded / ev.total) * file.size,
  319. bytesTotal: file.size,
  320. })
  321. })
  322. })
  323. xhr.addEventListener('load', (ev) => {
  324. timer.done()
  325. if (this.opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
  326. const body = this.opts.getResponseData(xhr.responseText, xhr)
  327. const uploadResp = {
  328. status: ev.target.status,
  329. body,
  330. }
  331. files.forEach((file) => {
  332. this.uppy.emit('upload-success', file, uploadResp)
  333. })
  334. return resolve()
  335. }
  336. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
  337. error.request = xhr
  338. emitError(error)
  339. return reject(error)
  340. })
  341. xhr.addEventListener('error', () => {
  342. timer.done()
  343. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
  344. emitError(error)
  345. return reject(error)
  346. })
  347. this.uppy.on('cancel-all', ({ reason } = {}) => {
  348. if (reason !== 'user') return
  349. timer.done()
  350. xhr.abort()
  351. })
  352. xhr.open(method.toUpperCase(), endpoint, true)
  353. // IE10 does not allow setting `withCredentials` and `responseType`
  354. // before `open()` is called.
  355. xhr.withCredentials = this.opts.withCredentials
  356. if (this.opts.responseType !== '') {
  357. xhr.responseType = this.opts.responseType
  358. }
  359. Object.keys(this.opts.headers).forEach((header) => {
  360. xhr.setRequestHeader(header, this.opts.headers[header])
  361. })
  362. xhr.send(formData)
  363. })
  364. }
  365. #getCompanionClientArgs (file) {
  366. const opts = this.getOptions(file)
  367. const allowedMetaFields = Array.isArray(opts.allowedMetaFields)
  368. ? opts.allowedMetaFields
  369. // Send along all fields by default.
  370. : Object.keys(file.meta)
  371. return {
  372. ...file.remote.body,
  373. protocol: 'multipart',
  374. endpoint: opts.endpoint,
  375. size: file.data.size,
  376. fieldname: opts.fieldName,
  377. metadata: Object.fromEntries(allowedMetaFields.map(name => [name, file.meta[name]])),
  378. httpMethod: opts.method,
  379. useFormData: opts.formData,
  380. headers: opts.headers,
  381. }
  382. }
  383. async #uploadFiles (files) {
  384. await Promise.allSettled(files.map((file, i) => {
  385. const current = parseInt(i, 10) + 1
  386. const total = files.length
  387. if (file.isRemote) {
  388. const getQueue = () => this.requests
  389. const controller = new AbortController()
  390. const removedHandler = (removedFile) => {
  391. if (removedFile.id === file.id) controller.abort()
  392. }
  393. this.uppy.on('file-removed', removedHandler)
  394. const uploadPromise = file.remote.requestClient.uploadRemoteFile(
  395. file,
  396. this.#getCompanionClientArgs(file),
  397. { signal: controller.signal, getQueue },
  398. )
  399. this.requests.wrapSyncFunction(() => {
  400. this.uppy.off('file-removed', removedHandler)
  401. }, { priority: -1 })()
  402. return uploadPromise
  403. }
  404. return this.#uploadLocalFile(file, current, total)
  405. }))
  406. }
  407. #handleUpload = async (fileIDs) => {
  408. if (fileIDs.length === 0) {
  409. this.uppy.log('[XHRUpload] No files to upload!')
  410. return
  411. }
  412. // No limit configured by the user, and no RateLimitedQueue passed in by a "parent" plugin
  413. // (basically just AwsS3) using the internal symbol
  414. if (this.opts.limit === 0 && !this.opts[internalRateLimitedQueue]) {
  415. this.uppy.log(
  416. '[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',
  417. 'warning',
  418. )
  419. }
  420. this.uppy.log('[XHRUpload] Uploading...')
  421. const files = this.uppy.getFilesByIds(fileIDs)
  422. const filesFiltered = filterNonFailedFiles(files)
  423. const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
  424. this.uppy.emit('upload-start', filesToEmit)
  425. if (this.opts.bundle) {
  426. // if bundle: true, we don’t support remote uploads
  427. const isSomeFileRemote = filesFiltered.some(file => file.isRemote)
  428. if (isSomeFileRemote) {
  429. throw new Error('Can’t upload remote files when the `bundle: true` option is set')
  430. }
  431. if (typeof this.opts.headers === 'function') {
  432. throw new TypeError('`headers` may not be a function when the `bundle: true` option is set')
  433. }
  434. await this.#uploadBundle(filesFiltered)
  435. } else {
  436. await this.#uploadFiles(filesFiltered)
  437. }
  438. }
  439. install () {
  440. if (this.opts.bundle) {
  441. const { capabilities } = this.uppy.getState()
  442. this.uppy.setState({
  443. capabilities: {
  444. ...capabilities,
  445. individualCancellation: false,
  446. },
  447. })
  448. }
  449. this.uppy.addUploader(this.#handleUpload)
  450. }
  451. uninstall () {
  452. if (this.opts.bundle) {
  453. const { capabilities } = this.uppy.getState()
  454. this.uppy.setState({
  455. capabilities: {
  456. ...capabilities,
  457. individualCancellation: true,
  458. },
  459. })
  460. }
  461. this.uppy.removeUploader(this.#handleUpload)
  462. }
  463. }